diff --git a/.github/workflows/deploy-pre-release.yml b/.github/workflows/deploy-pre-release.yml index 9c54a15f..2bd7cfe8 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 14950b7a..c506e0e5 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' @@ -43,16 +42,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: ${{ env.APP_VERSION }} - name: Xcode archive run: xcodebuild archive -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'generic/platform=iOS' -archivePath ${{ env.ARCHIVE_PATH }} @@ -146,12 +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 }}' - - name: Commit bump version - run: | - git add . - git commit -m "Bump version" - git push origin HEAD + tag_name: ${{ steps.retrieve-data.outputs.version }} - name: Update AltStore.json env: RELEASE_SIZE: ${{ steps.retrieve-data.outputs.size }} @@ -202,10 +193,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 '*%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 "**$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/.github/workflows/test.yml b/.github/workflows/test.yml index b97ba67b..1bd40ae3 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' diff --git a/.gitignore b/.gitignore index ee8327b3..4ebf6343 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.build .DS_Store +.xcode-home 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/.swiftlint.yml b/.swiftlint.yml index 65bd698e..195d1a26 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,18 +1,25 @@ disabled_rules: - - large_tuple - file_length - opening_brace - type_body_length - function_body_length - cyclomatic_complexity + - blanket_disable_command + - multiple_closures_with_trailing_closure -identifier_name: - excluded: - - x - - y - - id - - no +opt_in_rules: + - force_try + - force_unwrapping + +force_try: + severity: error + +force_unwrapping: + severity: error + +line_length: + warning: 120 + error: 120 excluded: - - EhPandaTests - EhPanda/App/Generated diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 15c18f3b..f78215a1 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -3,285 +3,34 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 100; 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 */; }; + 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 */; }; - 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 */; }; - 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 */; }; - 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 */; }; 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 */; }; - 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 */; }; EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = EAE63E2029E2A6330048C601 /* SwiftyBeaver */; }; /* End PBXBuildFile section */ @@ -305,290 +54,19 @@ /* 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 */ /* 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 = ""; }; 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 = ""; }; @@ -600,27 +78,63 @@ 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 = ""; }; /* 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 */ + A62261E62FC559F00055B5C0 /* .github */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = .github; + sourceTree = ""; + }; + 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; - buildActionMask = 2147483647; files = ( ); - runOnlyForDeploymentPostprocessing = 0; }; ABC3C7512593696C00E0C11B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; files = ( AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */, AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */, @@ -635,446 +149,21 @@ 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 */, ); - runOnlyForDeploymentPostprocessing = 0; }; ABF294C926D20F82004DD03A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; files = ( ); - runOnlyForDeploymentPostprocessing = 0; }; /* 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 */, - 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 */, - ); - 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 */, - ); - 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 */, - 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 = ( @@ -1082,40 +171,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 */, - ); - 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 */, @@ -1134,215 +195,10 @@ 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 */, - 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 */, - ); - 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 */, - 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 */, - 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 = ( - EA0BBD462E37CCB700DC8143 /* CODEOWNERS */, - EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */, - EA0C92442C3EB42300D211F6 /* workflows */, + A62261E62FC559F00055B5C0 /* .github */, ); name = GitHub; sourceTree = ""; @@ -1372,170 +228,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 = ( - 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 = ""; - }; - EAEC870B2A1F74D500E1A97A /* EhSetting */ = { - isa = PBXGroup; - children = ( - ABBC332726BE31AE0084A331 /* EhSettingView.swift */, - AB0929BD2780032400F107CA /* EhSettingReducer.swift */, - ); - path = EhSetting; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1550,6 +242,7 @@ buildRules = ( ); dependencies = ( + A66A766F2F77C89100FC07B8 /* PBXTargetDependency */, ); name = ShareExtension; productName = ShareExtension; @@ -1561,7 +254,6 @@ buildConfigurationList = ABC3C7632593696E00E0C11B /* Build configuration list for PBXNativeTarget "EhPanda" */; buildPhases = ( AB2E936227A24E0A00EA99F1 /* SwiftGen */, - AB69FE41263C328400716FBD /* SwiftLint */, ABC3C7502593696C00E0C11B /* Sources */, ABC3C7512593696C00E0C11B /* Frameworks */, ABC3C7522593696C00E0C11B /* Resources */, @@ -1570,8 +262,12 @@ buildRules = ( ); dependencies = ( + A66A766D2F77C88A00FC07B8 /* PBXTargetDependency */, AB5BE67F26B95FDD007D4A55 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + A6844B3D2F780C8600BBF6E5 /* EhPanda */, + ); name = EhPanda; packageProductDependencies = ( AB65059F26B0027800F91E9D /* SwiftUIPager */, @@ -1590,6 +286,8 @@ AB2EB9A1280251F600011A8A /* AlertKit */, AB2EB9A42802521700011A8A /* DeprecatedAPI */, EAE63E2029E2A6330048C601 /* SwiftyBeaver */, + 82E6B0042F185A0000D1F93A /* SDWebImageWebPCoder */, + 82E6B0072F185A0000D1F93A /* SDWebImageSwiftUI */, ); productName = EhPanda; productReference = ABC3C7542593696C00E0C11B /* EhPanda.app */; @@ -1606,8 +304,12 @@ buildRules = ( ); dependencies = ( + A66A76712F77C89600FC07B8 /* PBXTargetDependency */, ABF294D126D20F82004DD03A /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + A6844C652F780C9C00BBF6E5 /* EhPandaTests */, + ); name = EhPandaTests; productName = EhPandaTests; productReference = ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */; @@ -1621,7 +323,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1300; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 2640; TargetAttributes = { AB5BE67526B95FDD007D4A55 = { CreatedOnToolsVersion = 13.0; @@ -1636,7 +338,6 @@ }; }; buildConfigurationList = ABC3C74F2593696C00E0C11B /* Build configuration list for PBXProject "EhPanda" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -1668,7 +369,11 @@ AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */, AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */, EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, + 82E6B0032F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, + 82E6B0062F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, + A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); + preferredProjectObjectVersion = 100; productRefGroup = ABC3C7552593696C00E0C11B /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1683,90 +388,24 @@ /* 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 */, - 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 */, ); - runOnlyForDeploymentPostprocessing = 0; }; ABF294CA26D20F82004DD03A /* Resources */ = { isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; 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 */, - 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 */, ); - runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ @@ -1774,262 +413,59 @@ 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"; - }; - AB69FE41263C328400716FBD /* SwiftLint */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( + 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", + "", ); - 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"; }; /* 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 */, - 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 */, - 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 */, - 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 */, - 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 */, ); - runOnlyForDeploymentPostprocessing = 0; }; ABF294C826D20F82004DD03A /* Sources */ = { isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; files = ( - 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 */, - AB31CD3727B6695800F40E0A /* HTMLFilename.swift in Sources */, - AB3E9E7426D210B1008FE518 /* TestHelper.swift in Sources */, - AB31CD3B27B66E0300F40E0A /* ListParserTestType.swift in Sources */, - ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */, - ABAB5B9527EF023300198597 /* Extensions.swift in Sources */, ); - runOnlyForDeploymentPostprocessing = 0; }; /* 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 */; @@ -2042,56 +478,14 @@ }; /* 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 */ = { + AB5BE68226B95FDD007D4A55 /* Debug configuration for PBXNativeTarget "ShareExtension" */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 158; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2102,24 +496,26 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - AB5BE68326B95FDD007D4A55 /* Release */ = { + AB5BE68326B95FDD007D4A55 /* Release configuration for PBXNativeTarget "ShareExtension" */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 158; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2130,17 +526,20 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; - ABC3C7612593696E00E0C11B /* Debug */ = { + ABC3C7612593696E00E0C11B /* Debug configuration for PBXProject "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -2198,12 +597,13 @@ ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; - ABC3C7622593696E00E0C11B /* Release */ = { + ABC3C7622593696E00E0C11B /* Release configuration for PBXProject "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -2254,24 +654,23 @@ MTL_FAST_MATH = YES; OTHER_LDFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; - ABC3C7642593696E00E0C11B /* Debug */ = { + 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; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 158; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -2279,28 +678,29 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 3.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + 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; }; - ABC3C7652593696E00E0C11B /* Release */ = { + 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; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 158; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -2308,24 +708,27 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 3.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + 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; }; - ABF294D226D20F82004DD03A /* Debug */ = { + ABF294D226D20F82004DD03A /* Debug configuration for PBXNativeTarget "EhPandaTests" */ = { isa = XCBuildConfiguration; 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; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2337,22 +740,23 @@ PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + 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 */ = { + ABF294D326D20F82004DD03A /* Release configuration for PBXNativeTarget "EhPandaTests" */ = { isa = XCBuildConfiguration; 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; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2364,9 +768,10 @@ PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EhPanda.app/EhPanda"; }; @@ -2378,42 +783,62 @@ 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 */ /* 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; + }; + }; + 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"; @@ -2491,8 +916,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; @@ -2515,7 +944,7 @@ repositoryURL = "https://github.com/onevcat/Kingfisher"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.0.0; + minimumVersion = 8.0.0; }; }; ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { @@ -2523,7 +952,7 @@ repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + minimumVersion = 7.0.0; }; }; ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */ = { @@ -2531,7 +960,7 @@ repositoryURL = "https://github.com/tid-kijyun/Kanna"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.0.0; + minimumVersion = 6.0.0; }; }; EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */ = { @@ -2545,6 +974,31 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 82E6B0042F185A0000D1F93A /* SDWebImageWebPCoder */ = { + isa = XCSwiftPackageProductDependency; + package = 82E6B0032F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; + productName = SDWebImageWebPCoder; + }; + 82E6B0072F185A0000D1F93A /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 82E6B0062F185A0000D1F93A /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; + 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" */; @@ -2626,25 +1080,6 @@ productName = SwiftyBeaver; }; /* End XCSwiftPackageProductDependency section */ - -/* Begin XCVersionGroup section */ - ABC681F126898D46007BBD69 /* Model.xcdatamodeld */ = { - isa = XCVersionGroup; - children = ( - 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 = AB41DB5227B7EC5500DD3604 /* Model 7.xcdatamodel */; - path = Model.xcdatamodeld; - sourceTree = ""; - versionGroupType = wrapper.xcdatamodel; - }; -/* End XCVersionGroup 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 a94821f1..9021f501 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" : "e701a6d79a25f06dac1d0b3156dba1f0ad620d066aae2bdd39ab0ef6f21b8391", "pins" : [ { "identity" : "alertkit", @@ -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,44 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", - "version" : "7.12.0" + "revision" : "c152c1915f60c51e4afa0752656993ee5b3c63db", + "version" : "8.8.1" + } + }, + { + "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", + "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" } }, { @@ -69,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", "state" : { - "revision" : "7cca2d60925876b5953a2cf7341cd80fbeac983c", - "version" : "4.1.1" + "revision" : "e01b3d4f861412f8dcee8d93c417d2c2b0cdfd77", + "version" : "7.0.0" } }, { @@ -78,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", - "version" : "1.7.2" + "revision" : "206cbce3882b4de9aee19ce62ac5b7306cadd45b", + "version" : "1.7.3" } }, { @@ -96,8 +132,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 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "a9c3fecb5d31fc8aad5d8ba5d830924966d7fb15", - "version" : "1.23.0" + "revision" : "1eaa6fa2ee57ac42843283b9fd3457af408c858d", + "version" : "1.25.5" } }, { @@ -123,8 +159,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 +168,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 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", - "version" : "2.6.0" + "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", + "version" : "2.8.0" } }, { @@ -159,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", - "version" : "2.0.9" + "revision" : "25ac73741c3436605d61eceb5207e896973918e7", + "version" : "2.0.10" } }, { @@ -168,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", - "version" : "2.7.4" + "revision" : "bc27f8322bc30f6ce7d864d137dc77a6de8b57eb", + "version" : "2.8.0" } }, { @@ -177,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" + "revision" : "2b59c0c741e9184ab057fd22950b491076d42e91", + "version" : "603.0.0" } }, { @@ -190,6 +226,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", @@ -249,8 +294,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/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index e791012e..fc7c6b17 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -1,6 +1,6 @@ String { @@ -391,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 @@ -400,6 +477,20 @@ 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 @@ -439,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 @@ -458,6 +585,156 @@ 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 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. + 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 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.") + } + } + } + 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 + 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 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 @@ -1234,6 +1511,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 { @@ -1594,12 +1899,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") + /// 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 @@ -2099,6 +2406,42 @@ internal enum L10n { internal static let `none` = L10n.tr("Localizable", "struct.cookie_value.localized_string.none", fallback: "None") } } + 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") + } + 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") + } + /// 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") + } + /// 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 @@ -2138,6 +2481,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/Info.plist b/EhPanda/App/Info.plist index e852058b..2a009ffc 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/EhPanda/App/Tools/Clients/AppDelegateClient.swift b/EhPanda/App/Tools/Clients/AppDelegateClient.swift index 877b4bce..b484f7d1 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 18a92940..1f3a791b 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 fabfbce4..c7f9fc33 100644 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -5,13 +5,12 @@ 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 { @@ -32,8 +31,11 @@ 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 } } } else { diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index b2d89ff4..fd1d5fa2 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 { @@ -30,8 +30,9 @@ 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 } + if let expiresDate = cookie.expiresDate, + expiresDate <= .now { value = CookieValue( rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.expired ) @@ -79,16 +80,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) } } @@ -129,8 +179,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) @@ -207,7 +257,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/DFClient.swift b/EhPanda/App/Tools/Clients/DFClient.swift index c74e2c33..38909587 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+Updates.swift b/EhPanda/App/Tools/Clients/DatabaseClient+Updates.swift new file mode 100644 index 00000000..8e9e8c94 --- /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 bd0d55ba..910cc7fc 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 { @@ -58,7 +59,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 +83,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 +95,7 @@ extension DatabaseClient { return managedObject } - private func fetchOrCreate( + func fetchOrCreate( entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ((MO?) -> Void)? = nil ) -> MO { @@ -110,7 +111,7 @@ extension DatabaseClient { } } - private func batchUpdate( + func batchUpdate( entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ([MO]) -> Void ) { commitChanges(batchFetch( @@ -120,7 +121,7 @@ extension DatabaseClient { )) saveContext() } - private func update( + func update( entityType: MO.Type, predicate: NSPredicate? = nil, createIfNil: Bool = false, commitChanges: (MO) -> Void ) { @@ -141,7 +142,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,17 +152,17 @@ 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) + commitChanges: @escaping @Sendable ((MO) -> Void) ) { AppUtil.dispatchMainSync { let storedMO: MO? @@ -178,6 +179,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 +333,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/DeviceClient.swift b/EhPanda/App/Tools/Clients/DeviceClient.swift index 27bc1265..9abbc8c4 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+Cache.swift b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift new file mode 100644 index 00000000..3ddbb4be --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Cache.swift @@ -0,0 +1,322 @@ +// +// 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 + ) async { + let keys = urls + .compactMap(\.self) + .flatMap { + cacheKeys(for: $0, includeStableAlias: includeStableAlias) + } + + for key in Set(keys) { + try? await 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 if let fallbackURL = source.referenceURL { + let ext = fileExtension( + for: fallbackURL, + response: nil, + prefixData: cachedData + ) + relativePath = storage.makePageRelativePath( + index: index, + fileExtension: ext + ) + } else { + return nil + } + + 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 { + await 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 00000000..e55516aa --- /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 00000000..1275b568 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionFetch.swift @@ -0,0 +1,194 @@ +// +// 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 fetchOptionalVersionMetadata( + 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) + ) + } + + 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 fetchVersionMetadata( + gid: gid, + token: token + ) { + 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 00000000..c174449b --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionPerform.swift @@ -0,0 +1,274 @@ +// +// 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 await 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 + ) async throws { + let versionSignature = finalizeContext.versionSignature + let batchResult = finalizeContext.batchResult + let storedGalleryImageState = finalizeContext.storedGalleryImageState + let existingDownload = finalizeContext.existingDownload + 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, + 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 + ) + } + ) + } +} diff --git a/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift b/EhPanda/App/Tools/Clients/DownloadClient+ExecutionSupport.swift new file mode 100644 index 00000000..c4eeb382 --- /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 + ) async { + 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) + await 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: DownloadFileManager + ) 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 00000000..40666281 --- /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() -> DownloadFileManager { + 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 00000000..da15cb8e --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Networking.swift @@ -0,0 +1,376 @@ +// +// 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 prefixData.knownBinaryImageFileExtension ?? "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 + } + } + + 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 00000000..e45df0c7 --- /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 00000000..5ab926ba --- /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 00000000..07bd4115 --- /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: @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 + ) + } + } + } +} + +// 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) + } + } + + 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.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature + } else { + record.status = DownloadStatus.partial.rawValue + record.completedPageCount = Int64(recoveredCompletedPageCount) + record.latestRemoteVersionSignature = + context.latestSignature + ?? 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.latestSignature + ?? context.originalDownload.latestRemoteVersionSignature + } + + nonisolated 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 00000000..322518ff --- /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 00000000..602c213d --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+PersistenceNormalize.swift @@ -0,0 +1,259 @@ +// +// 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 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) + } + } + return validation + } + + 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 new file mode 100644 index 00000000..448a85b2 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+PublicAPI.swift @@ -0,0 +1,384 @@ +// +// 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 + ) + ) + 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?.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 + } + } + + 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 { + 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 + ) + } + _ = try? storage.refreshManifestPageFileHash( + folderURL: captureTarget.folderURL, + pageIndex: index, + relativePath: pageResult.relativePath + ) + _ = 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 00000000..b27f4467 --- /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 00000000..60af9010 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidation.swift @@ -0,0 +1,292 @@ +// +// 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 + ) ?? "" + + guard !prefixLooksLikeJSON(prefixData) else { + return nil + } + + 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 00000000..9fd04e25 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+ResponseValidationHelpers.swift @@ -0,0 +1,375 @@ +// +// 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 !prefixData.isKnownBinaryImageFormat 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 { + 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, + expectsHTML: Bool + ) -> 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 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 00000000..b0aa67fb --- /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 00000000..fd482c42 --- /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 00000000..f9631eec --- /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 + } + + nonisolated 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 00000000..dc1e2115 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient+Testing.swift @@ -0,0 +1,131 @@ +// +// 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 + ) + } + + func testingDetectResponseError( + data: Data, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + detectResponseError( + data: data, + response: response, + requestURL: requestURL + ) + } +} +#endif diff --git a/EhPanda/App/Tools/Clients/DownloadClient.swift b/EhPanda/App/Tools/Clients/DownloadClient.swift new file mode 100644 index 00000000..6fbb873f --- /dev/null +++ b/EhPanda/App/Tools/Clients/DownloadClient.swift @@ -0,0 +1,215 @@ +// +// DownloadClient.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +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 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 @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) }, + 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 + self.fetchDownload = fetchDownload + self.reconcileDownloads = reconcileDownloads + self.refreshDownloads = refreshDownloads + self.validateImageData = validateImageData + self.resumeQueue = resumeQueue + self.badges = badges + self.fetchVersionMetadata = fetchVersionMetadata + 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: sending FileManager = .default + ) -> Self { + let manager = DownloadManager( + storage: .init(rootURL: rootURL, fileManager: fileManager), + urlSession: urlSession + ) + Task { + await manager.reconcileDownloads() + await manager.resumeQueue() + } + 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) + } + 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() }, + 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) + }, + 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) } + ) + } +} + +// 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: {}, + validateImageData: { _ in nil }, + resumeQueue: {}, + badges: { _ in [:] }, + fetchVersionMetadata: { _, _ in .failure(.notFound) }, + 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()), + validateImageData: 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()), + 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/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index f82d9b31..a608ed0a 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 { @@ -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/HapticsClient.swift b/EhPanda/App/Tools/Clients/HapticsClient.swift index 25e4d7d5..58eef165 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 1f14343b..fe23feaf 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -7,23 +7,45 @@ import Photos import SwiftUI import Combine import Kingfisher +import SDWebImage 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 { 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 - 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) @@ -36,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): @@ -46,6 +85,7 @@ extension ImageClient { } } } + return result }, retrieveImage: { key in await withCheckedContinuation { continuation in @@ -66,18 +106,28 @@ 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) + where KingfisherManager.shared.cache.isCached(forKey: key) { + return await retrieveImage(key) } + return await downloadImage(url) } } 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 85ccc136..afd1abc8 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -7,16 +7,18 @@ import SwiftUI import Combine import Foundation import Kingfisher +import SDWebImage +import SDWebImageWebPCoder import SwiftyBeaver import UIImageColors 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 { @@ -54,23 +56,47 @@ extension LibraryClient { let config = KingfisherManager.shared.downloader.sessionConfiguration config.httpCookieStorage = HTTPCookieStorage.shared KingfisherManager.shared.downloader.sessionConfiguration = config + + 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 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) + } + ) } } }, 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) } ) } @@ -105,7 +131,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 7b6c8711..e24df02a 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 f7b163bf..05460317 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 { @@ -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 5a9136a2..7876ae44 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -6,17 +6,23 @@ import SwiftUI import Dependencies -struct URLClient { - let checkIfHandleable: (URL) -> Bool - let checkIfMPVURL: (URL?) -> Bool - let parseGalleryID: (URL) -> String +struct URLAnalysisResult { + let isGalleryImageURL: Bool + let pageIndex: Int? + let commentID: String? +} + +struct URLClient: Sendable { + let checkIfHandleable: @Sendable (URL) -> Bool + let checkIfMPVURL: @Sendable (URL?) -> Bool + let parseGalleryID: @Sendable (URL) -> String } 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/Clients/UserDefaultsClient.swift b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift index f886a8b2..8e747a7e 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 e13c56cc..99746f93 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 = - DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 + 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" @@ -61,6 +64,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/AnimatedImage_Extension.swift b/EhPanda/App/Tools/Extensions/AnimatedImage_Extension.swift new file mode 100644 index 00000000..ab496183 --- /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.. String? in + let str = Array(data).withUnsafeBufferPointer { ptr -> String? in guard let address = ptr.baseAddress else { return nil } return String(cString: address) } @@ -60,9 +60,54 @@ 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() { + case "apng", "gif", "png", "webp": true + default: false + } + } + + 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 + else { + return [self] + } - var isGIF: Bool { - pathExtension == "gif" + return [self, info.plainURL] } func appending(queryItems: [URLQueryItem]) -> URL { @@ -98,6 +143,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 @@ -145,7 +213,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 } @@ -168,8 +236,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..( + 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 ) -> 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 - ? .merge(effects, additionalEffects(&state, action)) - : effects + ? .merge(effects, additionalEffects(&state, action)) + : effects } } } @@ -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/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift index d9eb463c..219ae1d4 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 80c0910f..9aa23bd9 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/Extensions/ViewModifiers.swift b/EhPanda/App/Tools/Extensions/ViewModifiers.swift index cf2be369..d3e905ba 100644 --- a/EhPanda/App/Tools/Extensions/ViewModifiers.swift +++ b/EhPanda/App/Tools/Extensions/ViewModifiers.swift @@ -176,10 +176,10 @@ struct RoundedCorner: Shape { struct PreviewResolver { static func getPreviewConfigs(originalURL: URL?) -> (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 7782db41..00000000 --- a/EhPanda/App/Tools/Parser.swift +++ /dev/null @@ -1,1828 +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[.. 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 00000000..d449f8aa --- /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 00000000..516107c0 --- /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 00000000..e810f42c --- /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 00000000..1944d15e --- /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 00000000..d0fef0ce --- /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 00000000..8691a2b6 --- /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 00000000..3ca68c38 --- /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[.. 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 c7a0f58f..15225db3 100644 --- a/EhPanda/App/Tools/Utilities/CookieUtil.swift +++ b/EhPanda/App/Tools/Utilities/CookieUtil.swift @@ -9,7 +9,7 @@ import Foundation struct CookieUtil { static var didLogin: Bool { CookieUtil.verify(for: Defaults.URL.ehentai, isEx: false) - || CookieUtil.verify(for: Defaults.URL.exhentai, isEx: true) + || CookieUtil.verify(for: Defaults.URL.exhentai, isEx: true) } static func verify(for url: URL, isEx: Bool) -> Bool { @@ -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/DeviceUtil.swift b/EhPanda/App/Tools/Utilities/DeviceUtil.swift index 3a64e266..23018c87 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+Operations.swift b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift new file mode 100644 index 00000000..793ff3ca --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage+Operations.swift @@ -0,0 +1,326 @@ +// +// 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 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 } + 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 coverValidationFailure = validateCover( + folderURL: folderURL, + manifest: manifest + ) { + return coverValidationFailure + } + if let pageValidationFailure = validatePages( + folderURL: folderURL, + pages: manifest.pages + ) { + return pageValidationFailure + } + 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) + } + + 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 new file mode 100644 index 00000000..b7ae7a81 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DownloadFileStorage.swift @@ -0,0 +1,364 @@ +// +// DownloadFileStorage.swift +// EhPanda +// + +import Foundation +import CryptoKit +import Synchronization + +enum DownloadValidationState: Equatable, Sendable { + 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: Sendable { + let rootURL: URL + let fileManager: DownloadFileManager + + init( + rootURL: URL? = FileUtil.downloadsDirectoryURL, + fileManager: sending FileManager = .default + ) { + self.rootURL = rootURL + ?? FileUtil.temporaryDirectory.appendingPathComponent( + Defaults.FilePath.downloads, + isDirectory: true + ) + self.fileManager = DownloadFileManager(fileManager) + } + + 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 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) + } + + 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 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 JSONDecoder().decode(DownloadResumeState.self, from: data) + } + + func writeFailedPages(_ snapshot: DownloadFailedPagesSnapshot, folderURL: URL) throws { + 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 JSONDecoder().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 JSONEncoder().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 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 } + + let attributes: [FileAttributeKey: Any] + do { + attributes = try fileManager.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) + return false + } + guard let fileSize = (attributes[.size] as? NSNumber)?.intValue else { return false } + guard fileSize > 0 else { + try? fileManager.removeItem(at: url) + return false + } + + 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 + } + } +} + +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/FileUtil.swift b/EhPanda/App/Tools/Utilities/FileUtil.swift index 6521d3c3..48f33a33 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/Tools/Utilities/HapticsUtil.swift b/EhPanda/App/Tools/Utilities/HapticsUtil.swift index b0a3f3df..27ae1d05 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/App/Tools/Utilities/MarkdownUtil.swift b/EhPanda/App/Tools/Utilities/MarkdownUtil.swift index 03c5e4dd..e735a342 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 0fae2053..f751dda9 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 @@ -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 @@ -142,6 +146,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 +174,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 +191,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/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index b505d8d4..f3db0ae3 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -155,8 +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.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"; @@ -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,105 @@ "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.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.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."; +"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 a3ca9bac..a73a6c42 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,8 +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.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"; @@ -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,45 @@ "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.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.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"; +"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.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."; + // MARK: CommentsView "comments_view.title.comments" = "Comments"; @@ -356,6 +450,46 @@ // 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."; +"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"; "filters_view.title.advanced_settings" = "Advanced settings"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index fa68dba1..9a608f3b 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -155,8 +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.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" = "アカウント"; @@ -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,105 @@ "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.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.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 が見つかりません。"; +"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 b9266898..bfd6c7e5 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -155,8 +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.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" = "계정"; @@ -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,105 @@ "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.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.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가 없습니다."; +"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 19a4d6b0..1ffd80df 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,8 +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.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" = "账户"; @@ -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,44 @@ "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.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.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 +449,46 @@ // 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 页缺失。"; +"download_file_storage.validation.cover_image_corrupted" = "封面图片数据已损坏。"; +"download_file_storage.validation.page_image_corrupted" = "第 %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 948d5eb2..1f063409 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -155,8 +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.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" = "帳號設定"; @@ -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,103 @@ "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.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.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 頁缺失。"; +"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 0c4f150d..01d0caed 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -155,8 +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.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" = "帳號設定"; @@ -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,103 @@ "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.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.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 頁缺失。"; +"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 ad77e167..2afee3f5 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -155,8 +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.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" = "帳號設定"; @@ -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,103 @@ "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.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.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 頁缺失。"; +"download_file_storage.validation.cover_image_corrupted" = "封面圖片資料已損壞。"; +"download_file_storage.validation.page_image_corrupted" = "第 %d 頁圖片資料已損壞。"; diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 4b413e10..7dca95cc 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 14f67490..2bfdce39 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 5333c8a2..b7486597 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -17,12 +17,17 @@ 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 isAwaitingIgneousForLaunchAutomation = false } enum Action: BindableAction { case binding(BindingAction) case onScenePhaseChange(ScenePhase) + case runLaunchAutomation + case clearPadSettingSubstates case appDelegate(AppDelegateReducer.Action) case appRoute(AppRouteReducer.Action) @@ -33,21 +38,23 @@ 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 { 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 @@ -72,22 +79,46 @@ 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( - .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 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 @@ -106,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: @@ -129,6 +162,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)))) @@ -139,9 +179,6 @@ struct AppReducer { effects.append(hapticEffect) } } - if type == .setting && deviceClient.isPad() { - effects.append(.send(.appRoute(.setNavigation(.setting())))) - } return effects.isEmpty ? .none : .merge(effects) case .tabBar: @@ -149,14 +186,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)))) } @@ -173,6 +211,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 +225,21 @@ struct AppReducer { 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(.fetchGreetingDone(let result)): return .send(.appRoute(.fetchGreetingDone(result))) @@ -201,7 +255,25 @@ 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 94c04e57..85570679 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 @@ -56,8 +55,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 @@ -98,7 +97,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 +107,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() @@ -157,14 +158,14 @@ struct AppRouteReducer { state.route = nil switch result { case .success(let gallery): - return .merge( - .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)) - await send(.setHUDConfig(.error)) + await send(.setHUDConfig(.error())) } } diff --git a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift index 13afa9a0..749ac489 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 10820f45..5fac1e09 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/MODefinition/DownloadedGalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/DownloadedGalleryMO+CoreDataClass.swift new file mode 100644 index 00000000..191158c1 --- /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 00000000..b3f10ed1 --- /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 b3496841..ad0b4d8e 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/Migration/CoreDataMigrator.swift b/EhPanda/Database/Migration/CoreDataMigrator.swift index 5694e6be..dbef28a6 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) @@ -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/Model.xcdatamodeld/.xccurrentversion b/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion index f5b3fac0..e46b68c8 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 00000000..b6dbe1d3 --- /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 434f903c..8dd59cc9 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) + 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 @@ -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 { @@ -48,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: @@ -65,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.") } @@ -76,6 +81,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/Gallery/Gallery.swift b/EhPanda/Models/Gallery/Gallery.swift index 8d0aacb6..07243362 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 a6cf9928..fd699e8d 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, @@ -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/Models/Gallery/Language.swift b/EhPanda/Models/Gallery/Language.swift index dada937d..9fbe0ec2 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 00000000..167581dd --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadedGallery+Extensions.swift @@ -0,0 +1,236 @@ +// +// 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 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: + 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 + } + } +} + +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 + 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+Manifest.swift b/EhPanda/Models/Persistent/DownloadedGallery+Manifest.swift new file mode 100644 index 00000000..006934b7 --- /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+SignatureBuilder.swift b/EhPanda/Models/Persistent/DownloadedGallery+SignatureBuilder.swift new file mode 100644 index 00000000..c22d605e --- /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 00000000..5583bc79 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadedGallery+SupportTypes.swift @@ -0,0 +1,285 @@ +// +// 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 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) + } + + 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 + } +} + +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/Models/Persistent/DownloadedGallery.swift b/EhPanda/Models/Persistent/DownloadedGallery.swift new file mode 100644 index 00000000..dbbe1ae1 --- /dev/null +++ b/EhPanda/Models/Persistent/DownloadedGallery.swift @@ -0,0 +1,332 @@ +// +// DownloadedGallery.swift +// EhPanda +// + +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 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 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, 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 } + + 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 + } + +} diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index 4ea020c0..04849eae 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 @@ -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" @@ -199,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 @@ -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 d315149f..f739840e 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?) @@ -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 .gaugeWithDotsNeedle67percent + 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: @@ -141,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 00000000..9c61cd73 --- /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 31730762..6e3b751c 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 00000000..eeb717f5 --- /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 00000000..121b7638 --- /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 1d5a1944..cbb6a2df 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 @@ -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/Support/LiveText.swift b/EhPanda/Models/Support/LiveText.swift index db6271fb..6eccdeed 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/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 46d14119..92586bb6 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/Models/Tags/TagTranslation.swift b/EhPanda/Models/Tags/TagTranslation.swift index b50e01a9..00d8dbec 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 bfae94a8..ec46b8a9 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 cdfc8579..1a1c2a9a 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 cd7404ab..237df170 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 00000000..b566c9b8 --- /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 { _ in () } + .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 { _ in () } + .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 { _ in () } + .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 { _ in () } + .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 { _ in () } + .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 { _ in () } + .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 { _ in () } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/EhPanda/Network/Request+Detail.swift b/EhPanda/Network/Request+Detail.swift new file mode 100644 index 00000000..cee5a000 --- /dev/null +++ b/EhPanda/Network/Request+Detail.swift @@ -0,0 +1,276 @@ +// +// 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 + 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 { + 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 + 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 00000000..5e96f258 --- /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 00000000..82b178c1 --- /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 f70b746a..ab004c94 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -1,7 +1,6 @@ // -// PopularItemsRequest.swift +// Request.swift // EhPanda -// import Kanna import Combine @@ -9,7 +8,7 @@ import Foundation import ComposableArchitecture protocol Request { - associatedtype Response + associatedtype Response: Sendable var publisher: AnyPublisher { get } } @@ -35,13 +34,22 @@ extension Request { } } -private extension Publisher { +extension Publisher { func genericRetry() -> Publishers.Retry { 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() @@ -49,25 +57,25 @@ private 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) } } } } -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 @@ -77,6 +85,28 @@ 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: - 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 { @@ -140,948 +170,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() - .compactMap { resp -> HTMLDocument? in - var htmlDocument: HTMLDocument? - do { - htmlDocument = try Kanna.HTML(html: resp.data, encoding: .utf8) - } catch { - guard let parseError = error as? ParseError, parseError == .EncodingMismatch - else { return htmlDocument } - - htmlDocument = try? Kanna.HTML(html: resp.data.utf8InvalidCharactersRipped, encoding: .utf8) - } - return htmlDocument - } - .tryMap { - let (detail, state) = try Parser.parseGalleryDetail(doc: $0, gid: gid) - return ($0, detail, state, try Parser.parseAPIKey(doc: $0)) - } - .map { doc, detail, state, apiKey in - (detail, state, apiKey, try? Parser.parseGreeting(doc: doc)) - } - .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 b98ed509..05e7c3b8 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 { @@ -39,7 +38,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 +81,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)) @@ -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/Archives/ArchivesView.swift b/EhPanda/View/Detail/Archives/ArchivesView.swift index 828e681b..e53a95f2 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 432d412c..7bc4c6d8 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) @@ -73,8 +72,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 @@ -132,15 +131,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 { @@ -251,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/Comments/CommentsView.swift b/EhPanda/View/Detail/Comments/CommentsView.swift index 9a809df9..a6bc6bd2 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/LinkedText.swift b/EhPanda/View/Detail/Components/LinkedText.swift index 9e92ae5d..189cff6c 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 } @@ -42,15 +47,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) } } @@ -106,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 { @@ -131,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 } @@ -166,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/Components/TagDetailView.swift b/EhPanda/View/Detail/Components/TagDetailView.swift index 6b61d45d..d6b0cfd6 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 00000000..7521d3de --- /dev/null +++ b/EhPanda/View/Detail/DetailReducer+Actions.swift @@ -0,0 +1,215 @@ +// +// 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 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 + } + } + + 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) + } + + 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): + 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 .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) + } + + 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( + // 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 + } + } +} diff --git a/EhPanda/View/Detail/DetailReducer+Download.swift b/EhPanda/View/Detail/DetailReducer+Download.swift new file mode 100644 index 00000000..808a7da2 --- /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 await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in await 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 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 + 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 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 + 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 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 new file mode 100644 index 00000000..e3b7212a --- /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 downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) { + 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 { [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) + } + + 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) + } + + 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 { [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) + } + + 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/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 5e6c9823..84535eb7 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -24,8 +24,18 @@ struct DetailReducer { case galleryInfos(Gallery, GalleryDetail) } - private enum CancelID: CaseIterable { - case fetchDatabaseInfos, fetchGalleryDetail, rateGallery, favorGallery, unfavorGallery, postComment, voteTag + enum CancelID: CaseIterable { + case fetchDatabaseInfos + case fetchGalleryDetail + case fetchVersionMetadata + case fetchDownloadBadge + case observeDownload + case loadLocalPreviewURLs + case rateGallery + case favorGallery + case unfavorGallery + case postComment + case voteTag } @ObservableState @@ -33,20 +43,29 @@ 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 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() var torrentsState = TorrentsReducer.State() @@ -60,6 +79,26 @@ 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) @@ -72,7 +111,6 @@ struct DetailReducer { case clearSubStates case onPostCommentAppear case onAppear(String, Bool) - case toggleShowFullTitle case toggleShowUserRating case setCommentContent(String) @@ -80,7 +118,6 @@ struct DetailReducer { case updateRating(DragGesture.Value) case confirmRating(DragGesture.Value) case confirmRatingDone - case syncGalleryTags case syncGalleryDetail case syncGalleryPreviewURLs @@ -89,20 +126,36 @@ 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) + case fetchVersionMetadataIfNeeded + case fetchVersionMetadataDone(Result) case rateGallery case favorGallery(Int) 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) @@ -112,370 +165,38 @@ struct DetailReducer { case detailSearch(DetailSearchReducer.Action) } - @Dependency(\.databaseClient) private var databaseClient - @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.showsNewDawnGreeting = showsNewDawnGreeting - if state.detailSearchState.wrappedValue == nil { - state.detailSearchState.wrappedValue = .init() - } - if state.commentsState.wrappedValue == nil { - state.commentsState.wrappedValue = .init() - } - return .send(.fetchDatabaseInfos(gid)) - - 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 .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 detail = databaseClient.fetchGalleryDetail(gid: gid) { - state.galleryDetail = detail - } - return .merge( - .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 - return .send(.fetchGalleryDetail) - - case .fetchGalleryDetail: - guard state.loadingState != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.loadingState = .loading - 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) - ] - state.apiKey = apiKey - state.galleryDetail = galleryDetail - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - state.userRating = Int(galleryDetail.userRating) * 2 - 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 .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 } - return self.reduce( - into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - .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 } - return self.reduce( - into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - .map({ Action.detailSearch(.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) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) + .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) @@ -484,3 +205,41 @@ struct 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 { + 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 + } + + func shouldRequestVersionMetadata(state: State) -> Bool { + state.galleryDetail != nil + && state.shouldCheckForRemoteUpdates + && !state.didRequestVersionMetadata + } +} diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift index 70e330c2..5080f905 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/DetailSearch/DetailSearchView.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift index da4cccf5..29d7f842 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 00000000..7a76f2ab --- /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 00000000..c043d840 --- /dev/null +++ b/EhPanda/View/Detail/DetailView+HeaderSection.swift @@ -0,0 +1,282 @@ +// +// 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(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + } else if let progress = queuedDownloadProgress { + Button(action: downloadAction) { + progressIndicator( + progress: progress, + isDeterminate: false, + centerSystemName: activeDownloadIconSystemName + ) + } + .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()) + } + .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 { + 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 + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + 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 00000000..5eac2569 --- /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 00000000..7333252c --- /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 21d5d72f..daf1dae4 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -8,13 +8,78 @@ import Kingfisher import ComposableArchitecture import CommonMark +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 + } + } +} + struct DetailView: View { - @Bindable private var store: StoreOf - private let gid: String - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator + @Bindable var store: StoreOf + @State private var downloadDialog: DownloadDialog? + let gid: String + let user: User + @Binding var setting: Setting + let blurRadius: Double + let tagTranslator: TagTranslator init( store: StoreOf, gid: String, @@ -28,83 +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) { - HeaderSection( - gallery: store.gallery, - galleryDetail: store.galleryDetail ?? .empty, - user: user, - displaysJapaneseTitle: setting.displaysJapaneseTitle, - showFullTitle: store.showsFullTitle, - showFullTitleAction: { store.send(.toggleShowFullTitle) }, - favorAction: { store.send(.favorGallery($0)) }, - unfavorAction: { store.send(.unfavorGallery) }, - navigateReadingAction: { store.send(.setNavigation(.reading())) }, - navigateUploaderAction: { - if let uploader = store.galleryDetail?.uploader { - let keyword = "uploader:" + "\"\(uploader)\"" - store.send(.setNavigation(.detailSearch(keyword))) - } - } - ) - .padding(.horizontal) - DescriptionSection( - gallery: store.gallery, - galleryDetail: store.galleryDetail ?? .empty, - navigateGalleryInfosAction: { - if let galleryDetail = store.galleryDetail { - store.send(.setNavigation(.galleryInfos(store.gallery, galleryDetail))) - } + VStack(spacing: 30) { + if let error = store.loadingState.failed, + store.galleryDetail != nil { + offlineFallbackNotice(error: error) + .padding(.horizontal) } - ) - 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) - } - if !store.galleryPreviewURLs.isEmpty { - PreviewsSection( - pageCount: store.galleryDetail?.pageCount ?? 0, - previewURLs: store.galleryPreviewURLs, - navigatePreviewsAction: { store.send(.setNavigation(.previews)) }, - navigateReadingAction: { - store.send(.updateReadingProgress($0)) - store.send(.setNavigation(.reading())) + 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 @@ -119,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 @@ -130,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( @@ -167,726 +324,53 @@ 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)) - } - } - .background(navigationLinks) - .toolbar(content: toolbar) } -} -// 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 +// MARK: Actions 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: .docZipper) - } - .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 gallery: Gallery - private let galleryDetail: GalleryDetail - private let user: User - private let displaysJapaneseTitle: Bool - private let showFullTitle: Bool - private let showFullTitleAction: () -> Void - private let favorAction: (Int) -> Void - private let unfavorAction: () -> Void - private let navigateReadingAction: () -> Void - private let navigateUploaderAction: () -> Void - - init( - gallery: Gallery, galleryDetail: GalleryDetail, - user: User, displaysJapaneseTitle: Bool, showFullTitle: Bool, - showFullTitleAction: @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.displaysJapaneseTitle = displaysJapaneseTitle - self.showFullTitle = showFullTitle - self.showFullTitleAction = showFullTitleAction - 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 - } - - var body: some View { - HStack { - KFImage(gallery.coverURL) - .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() - - 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) - } - .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: .docRichtext) - 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 - 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() - .frame(width: width, height: height) - } - } - .withHorizontalSpacing(height: height) - } - } + private func handleDownloadAction() { + let options = setting.downloadOptionsSnapshot + switch store.downloadBadge { + case .none: + store.send(.startDownload(options)) + case .queued, .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) } } -} - -// 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 func runLaunchAutomationIfNeeded() { + store.send(.runLaunchAutomationIfNeeded(setting.downloadOptionsSnapshot)) } -} -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) + @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) } - .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() + .buttonStyle(.glass) + .buttonBorderShape(.capsule) } - .padding() - .frame(maxWidth: .infinity) - .background(backgroundColor) - .clipShape(shape) } - .glassEffect(.clear.interactive(), in: shape) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } } diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift index 4f0d4f05..68f2b54d 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/GalleryInfos/GalleryInfosView.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift index d28c870f..edf9f11b 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/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index fa41832d..085cd1a6 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,12 +66,13 @@ struct PreviewsReducer { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient 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 @@ -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 8df2e8a9..cefc17a4 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/DownloadInspectorReducer.swift b/EhPanda/View/Downloads/DownloadInspectorReducer.swift new file mode 100644 index 00000000..a35ac1f3 --- /dev/null +++ b/EhPanda/View/Downloads/DownloadInspectorReducer.swift @@ -0,0 +1,316 @@ +// +// DownloadInspectorReducer.swift +// EhPanda +// + +import Foundation +import ComposableArchitecture + +@Reducer +struct DownloadInspectorReducer { + @CasePathable + enum Route: Equatable { + case hud + } + + private enum CancelID { + case observeDownloads + case loadInspection + } + + @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 + loadingState = gid.isEmpty ? .idle : .loading + } + } + + enum Action: BindableAction { + case binding(BindingAction) + 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 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( + .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 .toggleDownloadPause: + guard let download = state.inspection?.download, + download.canTogglePause + else { return .none } + return .run { send in + await send(.toggleDownloadPauseDone(await downloadClient.togglePause(download.gid))) + } + + 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 + || 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 new file mode 100644 index 00000000..89774fdf --- /dev/null +++ b/EhPanda/View/Downloads/DownloadsReducer.swift @@ -0,0 +1,281 @@ +// +// 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) + case reading(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 readingState = ReadingReducer.State() + var inspectorState = DownloadInspectorReducer.State() + var quickSearchState = QuickSearchReducer.State() + var readingRequestID = UUID() + + 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 openReading(String) + case openReadingDone(UUID, String, Result<(DownloadedGallery, DownloadManifest), AppError>) + case toggleDownloadPause(String) + case toggleDownloadPauseDone(Result) + case updateDownload(String) + case updateDownloadDone(Result) + case deleteDownload(String) + case deleteDownloadDone(Result) + + case detail(DetailReducer.Action) + case reading(ReadingReducer.Action) + case inspector(DownloadInspectorReducer.Action) + case quickSearch(QuickSearchReducer.Action) + } + + @Dependency(\.downloadClient) private var downloadClient + + var body: some Reducer { + BindingReducer() + .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 { + 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.readingState = .init() + state.inspectorState = .init() + state.quickSearchState = .init() + return .merge( + .send(.detail(.teardown)), + .send(.reading(.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 .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))) + } + + 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 .reading(.onPerformDismiss): + return .send(.setNavigation(nil)) + + case .reading: + return .none + + case .inspector: + return .none + + case .quickSearch: + return .none + } + } + + 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 new file mode 100644 index 00000000..0711263d --- /dev/null +++ b/EhPanda/View/Downloads/DownloadsView+Subviews.swift @@ -0,0 +1,401 @@ +// +// DownloadsView+Subviews.swift +// EhPanda +// + +import SwiftUI +import SFSafeSymbols +import ComposableArchitecture + +struct DownloadInspectorView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @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) + } + + 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 { + ToolbarItem(placement: .cancellationAction) { + Button(role: .close, action: dismiss.callAsFunction) + } + } + .onAppear { + store.send(.onAppear) + } + } +} + +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 { + return L10n.Localizable.DownloadsView.Inspector.Page.none + } + return Self.formattedPageRanges(indices) + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemSymbol: status.symbol) + .foregroundStyle(status.tintColor) + .font(.title3) + .labelReservedIconWidth(24) + + 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) + } + + 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 { + static let inspectorSummaryOrder: [Self] = [ + .downloaded, + .pending, + .failed + ] + + 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 summaryTitle(count: Int) -> String { + "\(title) (\(count))" + } + + var symbol: SFSymbol { + switch self { + case .pending: .clock + case .downloaded: .checkmarkCircle + case .failed: .exclamationmarkCircle + } + } + + var tintColor: Color { + switch self { + 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 + } + } +} + +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 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/EhPanda/View/Downloads/DownloadsView.swift b/EhPanda/View/Downloads/DownloadsView.swift new file mode 100644 index 00000000..e943150b --- /dev/null +++ b/EhPanda/View/Downloads/DownloadsView.swift @@ -0,0 +1,367 @@ +// +// 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) + } + .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) + } + .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 + ) + } + } + .background(navigationLink) + .navigationTitle(L10n.Localizable.DownloadsView.Title.downloads) + .navigationBarTitleDisplayMode(.large) + .toolbar(content: toolbar) + } + +} + +// MARK: Subviews +private extension DownloadsView { + @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(.openReading(download.gid)) + } + .contextMenu { + downloadContextMenu(download) + } + .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.canTogglePause { + 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) + } + } + } + } + .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.canTogglePause { + 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 + 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: .dialLow) + .symbolRenderingMode(.hierarchical) + } + } + } +} + +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 0ccf39e5..4b89e8e0 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,27 +64,33 @@ struct FavoritesReducer { enum Action: BindableAction { case binding(BindingAction) + case onAppear case setNavigation(Route?) case setFavoritesIndex(Int) case clearSubStates 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 + 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 { 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 @@ -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 @@ -114,18 +128,20 @@ 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): 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 } @@ -133,8 +149,11 @@ struct FavoritesReducer { } state.rawPageNumber[targetFavIndex] = pageNumber state.rawGalleries[targetFavIndex] = galleries - state.sortOrder = sortOrder - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + state.sortOrder = fetchResult.sortOrder + return .merge( + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }), + .send(.fetchDownloadBadges(galleries.map(\.gid))) + ) case .failure(let error): state.rawLoadingState[targetFavIndex] = .failed(error) } @@ -148,24 +167,26 @@ 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): 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) }) @@ -174,6 +195,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 +204,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 b864bd9d..6554db7b 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -33,55 +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) - } + 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 { - 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/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index f27c5aee..aaf28198 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/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index 571b15c7..b49134fb 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/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift index df4ffc27..532c6c6e 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,23 +41,29 @@ 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 { 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 @@ -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 da046d56..26bdb04c 100644 --- a/EhPanda/View/Home/History/HistoryView.swift +++ b/EhPanda/View/Home/History/HistoryView.swift @@ -26,29 +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) - } - ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .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 00000000..79e35f73 --- /dev/null +++ b/EhPanda/View/Home/HomeReducer+Body.swift @@ -0,0 +1,223 @@ +// +// HomeReducer+Body.swift +// EhPanda +// + +import SwiftUI +import Kingfisher +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): + 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() { + 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 6165d918..8a8001b4 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -5,11 +5,14 @@ import SwiftUI import Kingfisher -import UIImageColors import ComposableArchitecture @Reducer struct HomeReducer { + enum CancelID { + case observeDownloads + } + @CasePathable enum Route: Equatable, Hashable { case detail(String) @@ -34,6 +37,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,15 +68,25 @@ 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) case analyzeImageColors(String, RetrieveImageResult) - case analyzeImageColorsDone(String, UIImageColors?) + case analyzeImageColorsDone(String, [Color]?) case fetchAllGalleries case fetchAllToplistsGalleries @@ -82,6 +96,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) @@ -91,182 +109,9 @@ struct HomeReducer { case detail(DetailReducer.Action) } - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.libraryClient) private var libraryClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == 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)) - } - } - } - - 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.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 .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.popularLoadingState = .failed(error) - } - return .none + @Dependency(\.databaseClient) var databaseClient + @Dependency(\.downloadClient) var downloadClient + @Dependency(\.libraryClient) var libraryClient - 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 .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - 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 .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - 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 .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 00000000..7eb9a711 --- /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 09d1940c..25fefcef 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,70 +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, - 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 }), - 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) + + 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) } - .opacity(store.popularGalleries.isEmpty ? 0 : 1).zIndex(2) - - 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) - } - .animation(.default, value: store.popularLoadingState) - .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 @@ -190,294 +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 navigateAction: (String) -> Void - private let webImageSuccessAction: (String, RetrieveImageResult) -> Void - - init( - galleries: [Gallery], pageIndex: Binding, currentID: String, - colors: [Color], navigateAction: @escaping (String) -> Void, - webImageSuccessAction: @escaping (String, RetrieveImageResult) -> Void - ) { - self.galleries = galleries - _pageIndex = pageIndex - self.currentID = currentID - self.colors = colors - 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 - } - - var body: some View { - Pager(page: page, data: galleries) { gallery in - Button { - navigateAction(gallery.id) - } label: { - GalleryCardCell(gallery: gallery, currentID: currentID, colors: colors) { - webImageSuccessAction(gallery.gid, $0) - } - .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 navigateAction: (String) -> Void - private let showAllAction: () -> Void - private let reloadAction: () -> Void - - init( - galleries: [Gallery], isLoading: Bool, - navigateAction: @escaping (String) -> Void, - showAllAction: @escaping () -> Void, - reloadAction: @escaping () -> Void - ) { - self.galleries = galleries - self.isLoading = isLoading - 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, navigateAction: navigateAction) - } - .withHorizontalSpacing(width: 0) - } - } - .frame(height: Defaults.ImageSize.rowH * 2 + 30) - } - } -} - -private struct VerticalCoverStack: View { - private let galleries: [Gallery] - private let navigateAction: (String) -> Void - - init(galleries: [Gallery], navigateAction: @escaping (String) -> Void) { - self.galleries = galleries - 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(gallery.coverURL).placeholder(placeholder).defaultModifier().scaledToFill() - .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) - } - } - - 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 navigateAction: (String) -> Void - private let showAllAction: () -> Void - private let reloadAction: () -> Void - - init( - galleries: [Int: [Gallery]], isLoading: Bool, - navigateAction: @escaping (String) -> Void, - showAllAction: @escaping () -> Void, - reloadAction: @escaping () -> Void - ) { - self.galleries = galleries - self.isLoading = isLoading - 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, - navigateAction: navigateAction - ) - if DeviceUtil.isPad { - VerticalToplistsStack( - galleries: galleries(type: type, range: 3...5), startRanking: 4, - navigateAction: navigateAction - ) - } - } - } - .padding(.horizontal, 20).padding(.vertical, 5) - } -} - -private struct VerticalToplistsStack: View { - private let galleries: [Gallery] - private let startRanking: Int - private let navigateAction: (String) -> Void - - init(galleries: [Gallery], startRanking: Int, navigateAction: @escaping (String) -> Void) { - self.galleries = galleries - self.startRanking = startRanking - 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 } @@ -505,7 +220,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 1b8df2a4..4095641b 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/Popular/PopularView.swift b/EhPanda/View/Home/Popular/PopularView.swift index 3c7e3c56..e98b7c75 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/ToplistsReducer.swift b/EhPanda/View/Home/Toplists/ToplistsReducer.swift index b36ca8fe..0226766d 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 @@ -122,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/Home/Toplists/ToplistsView.swift b/EhPanda/View/Home/Toplists/ToplistsView.swift index 0f53cb03..26a3881b 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/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 37413972..4765ce6a 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,12 +69,13 @@ struct WatchedReducer { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient 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 @@ -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 3a59da56..b2824d7c 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -26,59 +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) - } + 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 { - 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 00000000..9c35d59e --- /dev/null +++ b/EhPanda/View/Reading/ReadingReducer+Body.swift @@ -0,0 +1,318 @@ +// +// 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 await 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 await 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)) + + case .saveImage(let 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)) + + 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 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.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 = image.hasAnimatedFrames + state.hudConfig = .copiedToClipboardSucceeded + return .merge( + .send(.setNavigation(.hud)), + .run(operation: { _ in clipboardClient.saveImage(image, 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 = 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))))) + } + } + } 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 00000000..c9dd1a3b --- /dev/null +++ b/EhPanda/View/Reading/ReadingReducer+Database.swift @@ -0,0 +1,158 @@ +// +// 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:))) + ] + 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) + } 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 + localPageURLs.keys.forEach { + state.imageURLLoadingStates[$0] = .idle + state.previewLoadingStates[$0] = .idle + } + 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 00000000..9d93d944 --- /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 a0a0fd3f..9a32deb4 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -4,7 +4,6 @@ // import SwiftUI -import TTProgressHUD import ComposableArchitecture @Reducer @@ -30,31 +29,19 @@ struct ReadingReducer { } enum ImageAction { - case copy(Bool) - case save(Bool) - case share(Bool) - } - - private enum CancelID: CaseIterable { - case fetchImage - case fetchDatabaseInfos - case fetchPreviewURLs - case fetchThumbnailURLs - case fetchNormalImageURLs - case refetchNormalImageURLs - case fetchMPVKeys - case fetchMPVImageURL + case copy, save, share } @ObservableState struct State: Equatable { var route: Route? + var contentSource: ReadingContentSource = .remote var gallery: Gallery = .empty var galleryDetail: GalleryDetail? var readingProgress: Int = .zero var forceRefreshID: UUID = .init() - var hudConfig: TTProgressHUDConfig = .loading + var hudConfig: ProgressHUDConfigState = .loading() var webImageLoadSuccessIndices = Set() var imageURLLoadingStates = [Int: LoadingState]() @@ -63,6 +50,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 +64,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 } @@ -92,7 +85,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 @@ -105,7 +98,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 @@ -155,6 +148,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>) @@ -173,473 +170,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(\.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 - Reduce({ _, _ 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)) - ] - 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) - return .none - - case .onWebImageFailed(let index): - state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return .none - - case .reloadAllWebImages: - 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: - 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.isGIF), imageURL)) - - case .saveImage(let imageURL): - return .send(.fetchImage(.save(imageURL.isGIF), 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)) - - 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): - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - - case .syncThumbnailURLs(let thumbnailURLs): - return .run { [state] _ in - await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) - } - - case .syncImageURLs(let imageURLs, let originalImageURLs): - 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): - 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 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 .fetchPreviewURLs(let index): - 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): - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, false)) - } else { - return .send(.fetchThumbnailURLs(index)) - } - - case .refetchImageURLs(let index): - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, true)) - } else { - return .send(.refetchNormalImageURLs(index)) - } - - case .prefetchImages(let index, let prefetchLimit): - func getPrefetchImageURLs(range: ClosedRange) -> [URL] { - (range.lowerBound...range.upperBound).compactMap { index in - if let url = state.imageURLs[index] { - return url - } - return nil - } - } - func getFetchImageURLIndices(range: ClosedRange) -> [Int] { - (range.lowerBound...range.upperBound).compactMap { index in - 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.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): - 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.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): - 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 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 - } - } - .haptics( - unwrapping: \.route, - case: \.readingSetting, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.share, - hapticsClient: hapticsClient - ) - } + @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 00000000..5776ea8a --- /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 504213a3..dce207b8 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -5,6 +5,7 @@ import SwiftUI import Kingfisher +import Observation import SwiftUIPager import ComposableArchitecture @@ -12,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 - @StateObject private var liveTextHandler = LiveTextHandler() - @StateObject private var autoPlayHandler = AutoPlayHandler() - @StateObject private var gestureHandler = GestureHandler() - @StateObject private var pageHandler = PageHandler() - @StateObject private var page: Page = .first() + @State private var liveTextHandler = LiveTextHandler() + @State private var autoPlayHandler = AutoPlayHandler() + @State var gestureHandler = GestureHandler() + @State private var pageHandler = PageHandler() + @StateObject var page: Page = .first() init( store: StoreOf, @@ -36,8 +37,26 @@ 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 }) + @Bindable var bindableLiveTextHandler = liveTextHandler + @Bindable var bindablePageHandler = pageHandler + + return changeTriggers(content: { content }) .sheet(item: $store.route.sending(\.setNavigation).readingSetting) { _ in NavigationView { ReadingSettingView( @@ -90,14 +109,20 @@ struct ReadingView: View { } var content: some View { - ZStack { + @Bindable var bindableLiveTextHandler = liveTextHandler + @Bindable var bindablePageHandler = pageHandler + + return ZStack { backgroundColor.ignoresSafeArea() ZStack { 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), @@ -107,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 ) @@ -131,11 +159,11 @@ 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: store.previewURLs, + previewURLs: displayPreviewURLs, dismissGesture: controlPanelDismissGesture, dismissAction: { store.send(.onPerformDismiss) }, navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, @@ -148,8 +176,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( @@ -175,7 +226,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]) @@ -183,39 +233,26 @@ 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 { - 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 + let dataSource = store.state.containerDataSource(setting: setting, isLandscape: DeviceUtil.isLandscape) + let activeStackIndex = dataSource.indices.contains(page.index) ? dataSource[page.index] : nil HorizontalImageStack( index: index, isDualPage: isDualPage, + isActive: index == activeStackIndex, 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,366 +294,57 @@ 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] - ) - } - } - } -} - -// 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) } - ) + if imageURL.isFileURL { + analyzeLocalImage(at: imageURL, index: index) + return } - } -} - -// 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) - } + let cacheKeys = imageURL.imageCacheKeys(includeStableAlias: true) + Task { + await retrieveCachedImage(cacheKeys: ArraySlice(cacheKeys), 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) - } - 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) - } + 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 + ) + } else { + Logger.info("analyzeImageForLiveText local image not found", context: ["index": index]) } } -} -// 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 { - if url?.isGIF != true { - KFImage(url) - .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess).onFailure(onFailure) - } else { - KFAnimatedImage(url) - .placeholder(placeholder).fade(duration: 0.25) - .onSuccess(onSuccess).onFailure(onFailure) + private func retrieveCachedImage(cacheKeys: ArraySlice, index: Int) async { + guard let cacheKey = cacheKeys.first else { + Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) + return } - } - var body: some View { - if loadingState == .idle { - image(url: imageURL).scaledToFit().overlay( - LiveTextView( - liveTextGroups: liveTextGroups, - focusedLiveTextGroup: focusedLiveTextGroup, - tapAction: liveTextTapAction + 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 ) - .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: .exclamationmarkArrowTriangle2Circlepath) - } - .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) - .opacity(loadingState == .loading ? 0 : 1) - ProgressView().opacity(loadingState == .loading ? 1 : 0) - } - } + } else { + await retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) } - .frame(width: width, height: height) - } - } - private func reloadImage() { - if let error = loadingState.failed { - if case .webImageFailed = error { - loadRetryAction(index) + } catch { + if cacheKeys.count > 1 { + await retrieveCachedImage(cacheKeys: cacheKeys.dropFirst(), index: index) } else { - refetchAction(index) + Logger.info("analyzeImageForLiveText failed", context: ["index": index]) } } } - private func onSuccess(_: RetrieveImageResult) { - loadSucceededAction(index) - } - private func onFailure(_: KingfisherError) { - if imageURL != nil { - loadFailedAction(index) - } - } -} - -// 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)") - } - } } struct ReadingView_Previews: PreviewProvider { @@ -625,7 +353,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/ReadingViewComponents.swift b/EhPanda/View/Reading/ReadingViewComponents.swift new file mode 100644 index 00000000..5e998f66 --- /dev/null +++ b/EhPanda/View/Reading/ReadingViewComponents.swift @@ -0,0 +1,337 @@ +// +// ReadingViewComponents.swift +// EhPanda + +import SwiftUI +import Kingfisher +import SDWebImage +import SDWebImageSwiftUI + +// 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 isActive: 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, isActive: 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.isActive = isActive + 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, + isActive: isActive, + 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 isActive: 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, + isActive: 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.isActive = isActive + 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.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: { _, _, _ in 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) + } + } + + 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 var emptyProgress: Progress { + Progress(totalUnitCount: 1) + } + + 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/Reading/Support/AutoPlayHandler.swift b/EhPanda/View/Reading/Support/AutoPlayHandler.swift index 660f96d9..ccec7731 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() } @@ -18,7 +22,7 @@ final class AutoPlayHandler: ObservableObject { 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() @@ -26,7 +30,11 @@ final class AutoPlayHandler: ObservableObject { 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/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index f0fcdf0b..da656900 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 { @@ -193,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) { @@ -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/Reading/Support/GestureHandler.swift b/EhPanda/View/Reading/Support/GestureHandler.swift index c3b88c58..68e028c1 100644 --- a/EhPanda/View/Reading/Support/GestureHandler.swift +++ b/EhPanda/View/Reading/Support/GestureHandler.swift @@ -4,34 +4,39 @@ // 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(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 +111,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/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift index 28266c85..5c6d3eba 100644 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -14,25 +14,30 @@ 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? - private var processingRequests = [VNRequest]() + @ObservationIgnored + private var analysisTasks = [Int: Task]() - deinit { + isolated deinit { cancelRequests() } 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) { @@ -45,89 +50,82 @@ final class LiveTextHandler: ObservableObject { "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.. @@ -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,20 +69,19 @@ struct SearchReducer { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.downloadClient) private var downloadClient @Dependency(\.hapticsClient) private var hapticsClient 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 @@ -84,6 +89,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 +134,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 +174,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 +183,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/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift index c55a95fd..bc681d37 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 + .onChange(of: \.route) { _, state in + state.route == nil ? .merge( .send(.clearSubStates), .send(.fetchDatabaseInfos) ) : .none - } } Reduce { state, action in @@ -108,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 @@ -130,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/Search/SearchRootView+Keywords.swift b/EhPanda/View/Search/SearchRootView+Keywords.swift new file mode 100644 index 00000000..a352b1c3 --- /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 e2cd9eb3..6379808a 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 @@ -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() } @@ -258,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] @@ -409,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 0ba8c1d2..2de69ee5 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -28,53 +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) - } - ) - .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 { - 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/QuickSearchReducer.swift b/EhPanda/View/Search/Support/QuickSearchReducer.swift index 1c48d371..ad6377de 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/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index 5c60c160..d9e2e2c0 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) } @@ -70,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/AccountSetting/AccountSettingReducer.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift index e36a84fc..2a03bf6f 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() @@ -46,14 +45,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 @@ -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/Components/DownloadSettingView.swift b/EhPanda/View/Setting/Components/DownloadSettingView.swift new file mode 100644 index 00000000..7a00a257 --- /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 { + 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.DownloadSettingView.title) + } +} + +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/Components/WebView.swift b/EhPanda/View/Setting/Components/WebView.swift index 885152ca..f771497a 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 00000000..146c2782 --- /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 00000000..6eb62a70 --- /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 00000000..3c309bcc --- /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(L10n.Localizable.EhSettingView.Section.Title.profileSettings) { - 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 - ) - } - } - } - .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(L10n.Localizable.EhSettingView.Section.Title.imageLoadSettings) - } footer: { - Text(ehSetting.loadThroughHathSetting.description) - } - - Section( - L10n.Localizable.EhSettingView.Description.browsingCountry( - ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry - ) - .localizedKey - ) { - 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) - } - } - } - } -} - -// 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(L10n.Localizable.EhSettingView.Section.Title.imageSizeSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.imageResolution) - } - - if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { - Section(L10n.Localizable.EhSettingView.Section.Title.originalImages) { - Toggle( - L10n.Localizable.EhSettingView.Title.useOriginalImages, - isOn: useOriginalImagesBinding - ) - } - } - - Section(L10n.Localizable.EhSettingView.Description.imageSize) { - 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" - ) - } - } -} - -// 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(L10n.Localizable.EhSettingView.Section.Title.galleryNameDisplay) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.archiverSettings) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.frontPageSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.galleryCategory) - } - - Section(L10n.Localizable.EhSettingView.Description.displayMode) { - Picker(L10n.Localizable.EhSettingView.Title.displayMode, selection: $ehSetting.displayMode) { - ForEach(EhSetting.DisplayMode.allCases) { mode in - Text(mode.value) - .tag(mode) - } - } - .pickerStyle(.menu) - } - - Section(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) { - Toggle( - L10n.Localizable.EhSettingView.Title.showSearchRangeIndicator, - isOn: $ehSetting.showSearchRangeIndicator - ) - } - } -} - -// 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(L10n.Localizable.EhSettingView.Section.Title.optionalUIElements) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.favorites) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.favoriteCategories) - } - - Section(L10n.Localizable.EhSettingView.Description.favoritesSortOrder) { - Picker( - L10n.Localizable.EhSettingView.Title.favoritesSortOrder, - selection: $ehSetting.favoritesSortOrder - ) { - ForEach(EhSetting.FavoritesSortOrder.allCases) { order in - Text(order.value) - .tag(order) - } - } - .pickerStyle(.menu) - } - } -} - -// 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(L10n.Localizable.EhSettingView.Section.Title.ratings) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.tagFilteringThreshold) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.tagWatchingThreshold) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount).newlineBold() - + Text(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(L10n.Localizable.EhSettingView.Section.Title.excludedLanguages) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.excludedUploaders) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.searchResultCount) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.thumbnailSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.thumbnailLoadTiming) - } footer: { - Text(ehSetting.thumbnailLoadTiming.description) - } - - Section(L10n.Localizable.EhSettingView.Description.thumbnailConfiguration) { - 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) - } - } - } -} - -// 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(L10n.Localizable.EhSettingView.Section.Title.coverScaling) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.viewportOverride) - .newlineBold() - .appending(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(L10n.Localizable.EhSettingView.Section.Title.galleryComments) { - 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) - } - } -} - -// MARK: GalleryTagsSection -private struct GalleryTagsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.galleryTags) { - Picker(L10n.Localizable.EhSettingView.Title.tagsSortOrder, selection: $ehSetting.tagsSortOrder) { - ForEach(EhSetting.TagsSortOrder.allCases) { order in - Text(order.value) - .tag(order) - } - } - .pickerStyle(.menu) - } - } -} - -// MARK: GalleryPageThumbnailLabelingSection -private struct GalleryPageThumbnailLabelingSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.galleryPageThumbnailLabeling) { - Picker( - L10n.Localizable.EhSettingView.Title.showLabelBelowGalleryThumbnails, - selection: $ehSetting.galleryPageNumbering - ) { - ForEach(EhSetting.GalleryPageNumbering.allCases) { behavior in - Text(behavior.value) - .tag(behavior) - } - } - .pickerStyle(.menu) - } - } -} - -// 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(L10n.Localizable.EhSettingView.Section.Title.multiPageViewer) { - 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 - ) - } - } - } -} - -private extension String { - var lineCount: Int { - var count = 0 - enumerateLines { line, _ in - if !line.isEmpty { - count += 1 - } - } - return count - } -} - -private extension Text { - func newlineBold() -> Text { - bold() + Text("\n") - } - - func appending(_ string: some StringProtocol) -> Text { - self + Text(string) - } -} - struct EhSettingView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift index d63026bd..3a0f4eb0 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/GeneralSetting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift index 017f2520..a67f3565 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 358cd9a2..3c60ec6d 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) } } @@ -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/Logs/LogsView.swift b/EhPanda/View/Setting/Logs/LogsView.swift index 30afa5c1..9c300e69 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 00000000..0cdb8f17 --- /dev/null +++ b/EhPanda/View/Setting/SettingReducer+Body.swift @@ -0,0 +1,298 @@ +// +// 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 { + effects.append( + .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 + } + 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 await 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: + 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 + } + 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 00000000..e6e398cb --- /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 34af255a..320b381e 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 download case laboratory case about } @@ -41,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 @@ -57,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 } @@ -71,6 +70,7 @@ struct SettingReducer { case clearSubStates case syncAppIconType + case syncAppIconTypeDone(String?) case syncUserInterfaceStyle case syncSetting case syncTagTranslator @@ -98,415 +98,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 - Reduce({ _, _ in .send(.syncSetting) }) - } - .onChange(of: \.setting.galleryHost) { _, newValue in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .run(operation: { _ in userDefaultsClient.setValue(newValue.rawValue, .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.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) - } - ) - } - } - .onChange(of: \.setting.autoLockPolicy) { _, newValue in - Reduce { state, _ in - if newValue != .never && state.setting.backgroundBlurRadius == 0 { - state.setting.backgroundBlurRadius = 10 - } - 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.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.maximumScaleFactor) { _, newValue in - Reduce { state, _ in - if state.setting.doubleTapScaleFactor > newValue { - state.setting.doubleTapScaleFactor = newValue - } - return .send(.syncSetting) - } - } - .onChange(of: \.setting.doubleTapScaleFactor) { _, newValue in - Reduce { state, _ in - if state.setting.maximumScaleFactor < newValue { - state.setting.maximumScaleFactor = newValue - } - 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) }) - ) - } - } - - 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): - var effects = [Effect]() - if case .success(let response) = result { - effects.append(.run(operation: { _ in cookieClient.setCredentials(response: response) })) - } - effects.append(.send(.account(.loadCookies))) - return .merge(effects) - - 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/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index ccfcabc1..7ecb489f 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: \.download) { _ 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 .download: + return L10n.Localizable.Enum.SettingStateRoute.Value.download case .laboratory: return L10n.Localizable.Enum.SettingStateRoute.Value.laboratory case .about: @@ -168,10 +178,12 @@ extension SettingReducer.Route { return .circleRighthalfFilled case .reading: return .newspaperFill + case .download: + return .squareAndArrowDownOnSquareFill case .laboratory: return .testtube2 case .about: - return .pCircleFill + return .infoCircleFill } } } diff --git a/EhPanda/View/Support/Components/AlertView.swift b/EhPanda/View/Support/Components/AlertView.swift index 06481449..d4fe1770 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/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 5eb828b7..035106ea 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 + private let 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 95948467..fb58ea7f 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 + private let 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,13 +111,26 @@ 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) - 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/Cells/GalleryHistoryCell.swift b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift index 96be0877..86f4f793 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 { + private let 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 ff688ce5..87e66031 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 { + private let 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 784ffab3..7b64f8bb 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 + private let 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, @@ -48,7 +61,9 @@ struct GalleryThumbnailCell: View { } } 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 00000000..8bbb3630 --- /dev/null +++ b/EhPanda/View/Support/Components/DownloadBadgeLabel.swift @@ -0,0 +1,80 @@ +// +// DownloadBadgeLabel.swift +// EhPanda +// + +import SwiftUI + +struct DownloadBadgeLabel: View { + private let badge: DownloadBadge + private let isCompactStyle: Bool + + init?(badge: DownloadBadge, compact: Bool = false) { + guard badge != .none else { return nil } + + self.badge = badge + self.isCompactStyle = compact + } + + var body: some View { + 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 { + 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 00000000..7210777b --- /dev/null +++ b/EhPanda/View/Support/Components/DownloadBadgeStore.swift @@ -0,0 +1,52 @@ +// +// 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) + } + } + } + + 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 3014dffe..69e70a6d 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,8 +190,13 @@ private struct WaterfallList: View { Button { navigateAction?(gallery.id) } label: { - GalleryThumbnailCell(gallery: gallery, setting: setting, translateAction: translateAction) - .tint(.primary).multilineTextAlignment(.leading) + 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/Placeholder.swift b/EhPanda/View/Support/Components/Placeholder.swift index 89fea30e..db9c3b7f 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) } diff --git a/EhPanda/View/Support/Components/PreviewImageView.swift b/EhPanda/View/Support/Components/PreviewImageView.swift new file mode 100644 index 00000000..7ba74a17 --- /dev/null +++ b/EhPanda/View/Support/Components/PreviewImageView.swift @@ -0,0 +1,159 @@ +// +// 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) { + LocalPreviewThumbnailGenerator.make( + fileURL: fileURL, + maxPixelSize: maxPixelSize + ) + } + .value + + if let generatedThumbnail { + LocalPreviewThumbnailCache.shared.store(generatedThumbnail, forKey: cacheKey) + } + thumbnail = generatedThumbnail + } + +} + +private enum LocalPreviewThumbnailGenerator { + static func make(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) + } +} + +@MainActor +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/Support/Components/TagCloudView.swift b/EhPanda/View/Support/Components/TagCloudView.swift index ba0158ef..f043c8a6 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/Support/Components/TagSuggestionView.swift b/EhPanda/View/Support/Components/TagSuggestionView.swift index 1d1b1184..de817321 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 @@ -95,15 +96,17 @@ 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) } } } // 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) diff --git a/EhPanda/View/Support/FiltersReducer.swift b/EhPanda/View/Support/FiltersReducer.swift index 12a960f9..243c6dc9 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 diff --git a/EhPanda/View/TabBar/TabBarReducer.swift b/EhPanda/View/TabBar/TabBarReducer.swift index 2fc33e65..d3c78ed2 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/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift index ecaba9e5..f70aff1c 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 @@ -50,6 +56,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 +130,7 @@ enum TabBarItemType: Int, CaseIterable, Identifiable { case home case favorites case search + case downloads case setting } @@ -128,6 +143,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 +157,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 243eee1b..481bf53f 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/Models/ListParserTestType.swift b/EhPandaTests/Models/ListParserTestType.swift index 657e079a..c1d9dcdb 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 @@ -79,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/Parser/Other/BandwidthExceeded.html b/EhPandaTests/Resources/Parser/Other/BandwidthExceeded.html new file mode 100644 index 00000000..28d269f0 Binary files /dev/null and b/EhPandaTests/Resources/Parser/Other/BandwidthExceeded.html differ diff --git a/EhPandaTests/Resources/Parser/Other/ExLoginRequired.html b/EhPandaTests/Resources/Parser/Other/ExLoginRequired.html new file mode 100644 index 00000000..e69de29b diff --git a/EhPandaTests/Resources/Parser/Other/Kokomade.jpg b/EhPandaTests/Resources/Parser/Other/Kokomade.jpg new file mode 100644 index 00000000..df25f454 Binary files /dev/null and b/EhPandaTests/Resources/Parser/Other/Kokomade.jpg differ diff --git a/EhPandaTests/Resources/Utility/Extensions.swift b/EhPandaTests/Resources/Utility/Extensions.swift deleted file mode 100644 index 1b21cf70..00000000 --- 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 a26a72de..f64de674 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/DetailReducerDownloadTests.swift b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift new file mode 100644 index 00000000..84e61297 --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerDownloadTests.swift @@ -0,0 +1,165 @@ +// +// DetailReducerDownloadTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +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 00000000..60cffdcb --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataTests.swift @@ -0,0 +1,191 @@ +// +// DetailReducerMetadataTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +struct DetailReducerMetadataTests: DownloadFeatureTestCase { + @MainActor + @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 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 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) }) }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, + 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) }) }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, + 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 00000000..82d811c0 --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerMetadataUpdateTests.swift @@ -0,0 +1,172 @@ +// +// DetailReducerMetadataUpdateTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +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 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 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 [:] }, + fetchVersionMetadata: { _, _ in + .success(sampleVersionMetadata(gid: gallery.gid, token: gallery.token)) + }, + 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 00000000..83ac6d49 --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerObserveTests.swift @@ -0,0 +1,201 @@ +// +// DetailReducerObserveTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +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 00000000..18e8452d --- /dev/null +++ b/EhPandaTests/Tests/Download/DetailReducerPauseAndGuardTests.swift @@ -0,0 +1,166 @@ +// +// DetailReducerPauseAndGuardTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { + @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 = 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 00000000..ce3f676e --- /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 00000000..8583fe1b --- /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 new file mode 100644 index 00000000..571ecb46 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFeatureReducerTests.swift @@ -0,0 +1,7 @@ +// +// 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. +// diff --git a/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift new file mode 100644 index 00000000..597e9729 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestFactories.swift @@ -0,0 +1,385 @@ +// +// 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 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 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: Sendable { + let detailHTML: Data + let mpvHTML: Data + let metadataResponse: Data +} + +// MARK: - Stub Route Context + +struct StubRouteContext: Sendable { + 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, + persistenceContainer: NSPersistentContainer? = nil + ) -> (DownloadFileStorage, DownloadManager) { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + let storage = DownloadFileStorage( + rootURL: rootURL, fileManager: .default + ) + let container = persistenceContainer ?? PersistenceController.shared.container + let manager = DownloadManager( + storage: storage, + urlSession: URLSession(configuration: configuration), + persistenceContainer: container + ) + 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 DownloadFeatureTestStubRouter.stubResponse( + url: url, + contentType: "image/jpeg" + ), + Data([0xFF, 0xD8, 0xFF, 0xD9]) + ) + } + return try DownloadFeatureTestStubRouter.routeStubRequest( + url: url, + request: request, + context: context + ) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + } +} + +private enum DownloadFeatureTestStubRouter { + 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) + } + + 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 00000000..abdc6f0a --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestHelpers.swift @@ -0,0 +1,287 @@ +// +// 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, + persistenceContainer: PersistenceController.shared.container + ) + } + + 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 00000000..eb7a0647 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFeatureTestSupportTypes.swift @@ -0,0 +1,211 @@ +// +// DownloadFeatureTestSupportTypes.swift +// EhPandaTests +// + +import Foundation +import Synchronization +@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 handlers = SharedSessionStubHandlers() + + static func setHandler( + for sessionID: String, + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) + ) { + handlers.setHandler(for: sessionID, handler: handler) + } + + static func removeHandler(for sessionID: String) { + handlers.removeHandler(for: sessionID) + } + + private static func handler( + for request: URLRequest + ) -> (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + guard let sessionID = request.value( + forHTTPHeaderField: headerKey + ) else { + return nil + } + return handlers.handler(for: 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() {} +} + +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/DownloadFeatureTestTemporaryStorage.swift b/EhPandaTests/Tests/Download/DownloadFeatureTestTemporaryStorage.swift new file mode 100644 index 00000000..7d369c1b --- /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/DownloadFileStorageHashTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageHashTests.swift new file mode 100644 index 00000000..df11fbd9 --- /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/DownloadFileStorageRepairTests.swift b/EhPandaTests/Tests/Download/DownloadFileStorageRepairTests.swift new file mode 100644 index 00000000..5092cdae --- /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 00000000..c0b8fa0d --- /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 new file mode 100644 index 00000000..8168ba30 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadFileStorageTests.swift @@ -0,0 +1,321 @@ +// +// DownloadFileStorageTests.swift +// EhPandaTests +// + +import Foundation +import Testing +@testable import EhPanda + +struct DownloadFileStorageTests { + @Test + 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 = try 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) + + #expect(loadedManifest == manifest) + #expect(storage.validate(download: download) == .valid) + } + + @Test + func testEnsureRootDirectoryMarksDownloadsFolderExcludedFromBackup() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + + let resourceValues = try rootURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + #expect(resourceValues.isExcludedFromBackup == true) + } + + @Test + 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 + ) + + #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) } + + 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 + ) + + #expect( + storage.validate(download: download) == .missingFiles("Page 1 is missing.") + ) + #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) } + + 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 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 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) + + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, expectedPageCount: 2) == [ + 2: "pages/0002.png" + ] + ) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) + } + + @Test + func testIsReadableAssetFileDoesNotDeleteFileWhenAttributesLookupFails() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + let fileURL = rootURL.appendingPathComponent("cover.jpg") + try Data([0xFF, 0xD8, 0xFF]).write(to: fileURL, options: .atomic) + let storage = DownloadFileStorage( + rootURL: rootURL, + fileManager: ThrowingAttributesFileManager(failingPath: 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) } + + let unsafeTitle = " /Alpha\\\\Beta:\n\tGamma Delta \(String(repeating: "X", count: 200)). " + let relativePath = storage.makeFolderRelativePath(gid: "123", title: unsafeTitle) + + #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) + } +} + +private final class ThrowingAttributesFileManager: FileManager { + let 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 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) 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/DownloadFilterAndBadgeTests.swift b/EhPandaTests/Tests/Download/DownloadFilterAndBadgeTests.swift new file mode 100644 index 00000000..3ebffb37 --- /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 00000000..f26fbe64 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadImageErrorTests.swift @@ -0,0 +1,181 @@ +// +// 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 + } + } + + @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) + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift b/EhPandaTests/Tests/Download/DownloadImageParsingCacheTests.swift new file mode 100644 index 00000000..26962776 --- /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, persistenceContainer: container) + 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 00000000..fb02af33 --- /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, persistenceContainer: container) + 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 00000000..3220de69 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadInspectorLoadTests.swift @@ -0,0 +1,365 @@ +// +// DownloadInspectorLoadTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +struct DownloadInspectorLoadTests: DownloadFeatureTestCase { + @MainActor + @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]) + } + + @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 + +private extension DownloadInspectorLoadTests { + func makeInspectorStore( + 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) + 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 [:] }, + updateRemoteSignature: { _, _ in .none }, + 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 new file mode 100644 index 00000000..c7a43ba3 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadInspectorRetryTests.swift @@ -0,0 +1,155 @@ +// +// DownloadInspectorRetryTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +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 00000000..252df6b0 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadInspectorSkipTests.swift @@ -0,0 +1,101 @@ +// +// DownloadInspectorSkipTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadInspectorSkipTests: DownloadFeatureTestCase { + @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 + } + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadIpBanTests.swift b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift new file mode 100644 index 00000000..d80a84ae --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadIpBanTests.swift @@ -0,0 +1,69 @@ +// +// 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), + persistenceContainer: PersistenceController.shared.container + ) + 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 00000000..e96fa9d9 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadManagerCaptureTests.swift @@ -0,0 +1,143 @@ +// +// 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, + persistenceContainer: container + ) + 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, persistenceContainer: container) + 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 00000000..f7f54772 --- /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, persistenceContainer: container) + + 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 00000000..0514a4f9 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadManagerStorageTests.swift @@ -0,0 +1,192 @@ +// +// 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, + persistenceContainer: container + ) + + 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, + persistenceContainer: container + ) + + 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, + persistenceContainer: container + ) + + 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 00000000..67a38d34 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadObserverBatchTests.swift @@ -0,0 +1,161 @@ +// +// DownloadObserverBatchTests.swift +// EhPandaTests +// + +import Foundation +import CoreData +import ComposableArchitecture +import Kingfisher +import UIKit +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadObserverBatchTests: DownloadFeatureTestCase { + @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 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 00000000..726a7df8 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadObserverReadingTests.swift @@ -0,0 +1,227 @@ +// +// DownloadObserverReadingTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +struct DownloadObserverReadingTests: DownloadFeatureTestCase { + @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 (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 00000000..8771342c --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadObserverRefreshTests.swift @@ -0,0 +1,159 @@ +// +// DownloadObserverRefreshTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +struct DownloadObserverRefreshTests: DownloadFeatureTestCase { + @MainActor + @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() + } + + @MainActor + @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 00000000..c391069a --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadPauseAndReconcileTests.swift @@ -0,0 +1,265 @@ +// +// 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), + persistenceContainer: container + ) + + 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), + persistenceContainer: container + ) + + 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), + persistenceContainer: container + ) + + 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), + persistenceContainer: container + ) + + 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), persistenceContainer: container + ) + 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 00000000..79701a01 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadProcessCacheTests.swift @@ -0,0 +1,335 @@ +// +// 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, + persistenceContainer: container + ) + let storage = cacheTestManager.storage + let manager = cacheTestManager.manager + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let (cachedKeys, _) = 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( + .init( + container: container, + storage: storage, + manager: manager, + 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 +} + +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, + 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), + persistenceContainer: persistenceContainer + ) + let content = StubHandlerContent( + detailHTML: try makeUniqueDetailHTML(gid: gid), + 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 = 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") { + 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) + } + } + + 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, + 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( + 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, + 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(_ 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, + remoteVersionSignature: setup.oldVersionSignature, + latestRemoteVersionSignature: setup.oldVersionSignature + ) + 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) + #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 insertPersistedGalleryState( + in: setup.container, gid: setup.gid, + previewURLs: [1: combinedPreviewURL], + imageURLs: [1: staleStoredPageURL] + ) + } + try setupCacheTestTemporaryFolder( + storage: setup.storage, gid: setup.gid, + pageIndex: setup.pageIndex, oldPageCount: oldPageCount, + oldVersionSignature: setup.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 00000000..1930e6f0 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadProcessTests.swift @@ -0,0 +1,165 @@ +// +// 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, persistenceContainer: container + ) + 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 00000000..28547724 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadRetryMinimalSourceTests.swift @@ -0,0 +1,140 @@ +// +// 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, persistenceContainer: container + ) + 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 await assertRetrySkipsCompletedSelection( + .init( + container: container, + storage: storage, + manager: manager, + gid: gid, + pageIndex: pageIndex, + setup: setup, + manifest: manifest + ) + ) + } +} + +// MARK: - Minimal Source Test Result + +private struct MinimalSourceTestResult { + let recorder: RequestRecorder + let versionSignature: String + 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 { + 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 00000000..9682fb14 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadRetryPagesTests.swift @@ -0,0 +1,138 @@ +// +// 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, persistenceContainer: container) + 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, + persistenceContainer: container + ) + + 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 00000000..51ee20e7 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadRetryUpdateFallbackTests.swift @@ -0,0 +1,202 @@ +// +// 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, persistenceContainer: container + ) + 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, persistenceContainer: container + ) + 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 new file mode 100644 index 00000000..6fa2071f --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadSignatureBuilderTests.swift @@ -0,0 +1,183 @@ +// +// DownloadSignatureBuilderTests.swift +// EhPandaTests +// + +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" + ) + ) + + #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 + ) + ) + + #expect(signature == "chain:\(sampleGallery.gid):\(sampleGallery.token)") + } + + @Test + func testMakeReturnsHashPrefixedFallbackSignature() { + let signature = DownloadSignatureBuilder.make( + gallery: sampleGallery, + detail: sampleDetail, + host: .ehentai, + previewURLs: [:] + ) + + #expect(signature.hasPrefix("hash:")) + } + + @Test + func testHashAndChainSignaturesAreIncomparableForUpdateCheck() { + #expect( + DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: "chain:newgid:newtoken", + gid: sampleGallery.gid, + token: sampleGallery.token + ) == .incomparable + ) + #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)" + + #expect( + DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: latestSignature, + gid: sampleGallery.gid, + token: sampleGallery.token + ) == .same + ) + #expect( + DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: latestSignature, + gid: sampleGallery.gid, + token: sampleGallery.token + ) == latestSignature + ) + } + + @Test + func testDoNotCanonicalizeHashWhenLatestChainPointsToDifferentCurrentGallery() { + #expect( + DownloadSignatureBuilder.hasUpdateComparison( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: "chain:othergid:othertoken", + gid: sampleGallery.gid, + token: sampleGallery.token + ) == .incomparable + ) + #expect( + DownloadSignatureBuilder.canonicalizeStoredSignatureIfSafe( + remoteVersionSignature: "hash:abc", + latestRemoteVersionSignature: "chain:othergid:othertoken", + gid: sampleGallery.gid, + token: sampleGallery.token + ) == 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) + } +} diff --git a/EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift b/EhPandaTests/Tests/Download/DownloadSignaturePreviewTests.swift new file mode 100644 index 00000000..d6f39297 --- /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 00000000..4a9b7ba3 --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadVersionSignatureTests.swift @@ -0,0 +1,159 @@ +// +// 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, persistenceContainer: container) + 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, + 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") + } + +} diff --git a/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift new file mode 100644 index 00000000..520c936f --- /dev/null +++ b/EhPandaTests/Tests/Download/DownloadsReducerActionTests.swift @@ -0,0 +1,242 @@ +// +// DownloadsReducerActionTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct DownloadsReducerActionTests: DownloadFeatureTestCase { + @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 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 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/DownloadsReducerReadingDismissTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerReadingDismissTests.swift new file mode 100644 index 00000000..4b090d6d --- /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/DownloadsReducerRefreshTests.swift b/EhPandaTests/Tests/Download/DownloadsReducerRefreshTests.swift new file mode 100644 index 00000000..2be66a27 --- /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 00000000..fda6ed38 --- /dev/null +++ b/EhPandaTests/Tests/Download/PreviewsReducerDownloadTests.swift @@ -0,0 +1,158 @@ +// +// PreviewsReducerDownloadTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +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 { + 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 }, + refreshDownloads: {}, + resumeQueue: {}, + badges: { _ in [:] }, + updateRemoteSignature: { _, _ in .none }, + enqueue: { _ in .success(()) }, + togglePause: { _ in .success(()) }, + retry: { _, _ in .success(()) }, + delete: { _ in .success(()) }, + loadManifest: { _ in .failure(.notFound) }, + loadLocalPageURLs: loadLocalPageURLsResult + ) + } + + 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 00000000..95709dad --- /dev/null +++ b/EhPandaTests/Tests/Download/ReadingReducerDownloadTests.swift @@ -0,0 +1,196 @@ +// +// ReadingReducerDownloadTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +@MainActor +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 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 { + 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 00000000..1c67d98a --- /dev/null +++ b/EhPandaTests/Tests/Download/ReadingReducerLocalTests.swift @@ -0,0 +1,111 @@ +// +// ReadingReducerLocalTests.swift +// EhPandaTests +// + +import Foundation +import ComposableArchitecture +import Testing +@testable import EhPanda + +@Suite(.serialized) +struct ReadingReducerLocalTests: DownloadFeatureTestCase { + @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() + + #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/GalleryDetailParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift index 50d83112..27450a53 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift @@ -4,35 +4,39 @@ // 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 6f4d2a12..2c54e43a 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) @@ -16,14 +17,21 @@ 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") + 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/" + + "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 { 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 215f1295..8ef61f90 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 c03e8a2c..3b0abc13 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 5a4672ba..f7129870 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 new file mode 100644 index 00000000..219d7fa4 --- /dev/null +++ b/EhPandaTests/Tests/Parser/Other/DownloadPageErrorParserTests.swift @@ -0,0 +1,64 @@ +// +// DownloadPageErrorParserTests.swift +// EhPandaTests +// + +import Kanna +import Testing +@testable import EhPanda + +struct DownloadPageErrorParserTests: TestHelper { + @Test + func testIPBannedPageMapsToIPBanned() throws { + let document = try htmlDocument(filename: .ipBanned) + + #expect( + Parser.parseDownloadPageError(doc: document) == .ipBanned(.minutes(59, seconds: 48)) + ) + } + + @Test + func testNormalGalleryDetailPageDoesNotMapToDownloadError() throws { + let document = try htmlDocument(filename: .galleryDetail) + + #expect(Parser.parseDownloadPageError(doc: document) == nil) + } + + @Test + func testAuthenticationRequiredMarkersMapToAuthenticationRequired() throws { + let document = try Kanna.HTML( + html: """ + + + + +

Access to ExHentai.org is restricted.

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

Invalid page

Gallery not found.

+

Key missing.

Keep trying.

+ """, + encoding: .utf8 + ) + + #expect(Parser.parseDownloadPageError(doc: document) == .notFound) + #expect(Parser.parseDownloadPageError(content: "Gallery not found") == .notFound) + #expect(Parser.parseDownloadPageError(content: "Keep trying") == .notFound) + } + + @Test + func testGalleryNotAvailableIsNotHardMappedToDownloadError() { + #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 aa9c6775..0f37df6d 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 6da76019..b00f8fb6 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 new file mode 100644 index 00000000..fadf4e6b --- /dev/null +++ b/EhPandaTests/Tests/Parser/Other/SettingDownloadTests.swift @@ -0,0 +1,115 @@ +// +// SettingDownloadTests.swift +// EhPandaTests +// + +import SwiftUI +import Testing +@testable import EhPanda + +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.downloadThreadMode == .single) + #expect(setting.downloadAllowCellular) + #expect(setting.downloadAutoRetryFailedPages) + } + + @Test + func testDownloadOptionsSnapshotMatchesSettingValues() { + var setting = Setting() + setting.downloadThreadMode = .quadruple + setting.downloadAllowCellular = false + setting.downloadAutoRetryFailedPages = false + + #expect( + setting.downloadOptionsSnapshot == DownloadOptionsSnapshot( + threadMode: .quadruple, + allowCellular: false, + autoRetryFailedPages: false + ) + ) + } + + @Test + func testLegacyDownloadOptionsSnapshotDecodesWithoutOriginalImageField() throws { + let data = Data(""" + { + "threadMode": "triple", + "useOriginalImages": true, + "allowCellular": false, + "autoRetryFailedPages": false + } + """.utf8) + + let snapshot = try JSONDecoder().decode(DownloadOptionsSnapshot.self, from: data) + + #expect( + snapshot == DownloadOptionsSnapshot( + threadMode: .triple, + allowCellular: false, + autoRetryFailedPages: false + ) + ) + } + + @Test + 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) == [ + "download::h/123/456/image.webp", + "https://alpha.hath.network/h/123/456/image.webp?download=1" + ] + ) + } + + @Test + 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() 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") + #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")) + let combinedURL = URLUtil.combinedPreviewURL( + plainURL: plainURL, + width: "200", + height: "300", + offset: "40" + ) + + #expect(combinedURL.previewCacheCleanupURLs() == [combinedURL, plainURL]) + #expect(plainURL.previewCacheCleanupURLs() == [plainURL]) + } +} diff --git a/Scripts/bump-version.sh b/Scripts/bump-version.sh new file mode 100755 index 00000000..54fe19e7 --- /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)." diff --git a/ShareExtension/Info.plist b/ShareExtension/Info.plist index 4c8c1c00..ea3b19ad 100644 --- a/ShareExtension/Info.plist +++ b/ShareExtension/Info.plist @@ -2,8 +2,6 @@ - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) NSExtension NSExtensionAttributes diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index d9df2d99..515b55ca 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -3,6 +3,7 @@ // ShareExtension // +import AppIntents import UIKit class ShareViewController: UIViewController { @@ -21,21 +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) + .replacingOccurrences(of: scheme, with: "ehpanda")) { + 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) + } } ) }