diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4b6631bf..dae4a395 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,8 +5,8 @@ on: - main types: [closed] env: - DEVELOPER_DIR: /Applications/Xcode_13.1.app - APP_VERSION: '1.5.4' + DEVELOPER_DIR: /Applications/Xcode_13.2.1.app + APP_VERSION: '2.0.0' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' @@ -31,6 +31,8 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Install dependencies + run: brew install rswift - name: Show Xcode version run: xcodebuild -version - name: Run tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95972c3a..4f0eced1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,13 +2,15 @@ name: Test on: [push] env: SCHEME_NAME: 'EhPanda' - DEVELOPER_DIR: /Applications/Xcode_13.0.app + DEVELOPER_DIR: /Applications/Xcode_13.2.1.app jobs: Test: runs-on: macos-11 steps: - name: Checkout uses: actions/checkout@v2 + - name: Install dependencies + run: brew install rswift - name: Show Xcode version run: xcodebuild -version - name: Run tests diff --git a/.gitignore b/.gitignore index ee8327b3..b8321d0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store EhPanda.xcodeproj/xcuserdata -EhPanda.xcodeproj/project.xcworkspace/xcuserdata \ No newline at end of file +EhPanda.xcodeproj/project.xcworkspace/xcuserdata +EhPanda/App/R.generated.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 22adb80e..ed43722b 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -15,3 +15,4 @@ identifier_name: excluded: - EhPandaTests + - EhPanda/App/R.generated.swift diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index e7c66f2b..240b51ae 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -3,18 +3,44 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 55; objects = { /* Begin PBXBuildFile section */ + AB0929B6277F043D00F107CA /* AccountSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929B5277F043D00F107CA /* AccountSettingStore.swift */; }; + AB0929BE2780032400F107CA /* EhSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BD2780032400F107CA /* EhSettingStore.swift */; }; + AB0929C027805A8200F107CA /* LoginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BF27805A8200F107CA /* LoginStore.swift */; }; + AB0929C6278160AE00F107CA /* LibraryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C5278160AE00F107CA /* LibraryClient.swift */; }; + AB0929C82781938A00F107CA /* DFClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C72781938A00F107CA /* DFClient.swift */; }; + AB0929CA278196ED00F107CA /* CookiesClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C9278196ED00F107CA /* CookiesClient.swift */; }; + AB0929CC2781A0B000F107CA /* HapticClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CB2781A0B000F107CA /* HapticClient.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 /* GeneralSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D52782A65F00F107CA /* GeneralSettingStore.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 */; }; AB0F68AF26A6D92F00AC3A54 /* DeprecatedAPI in Frameworks */ = {isa = PBXBuildFile; productRef = AB0F68AE26A6D92F00AC3A54 /* DeprecatedAPI */; }; AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10117D26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift */; }; AB10118026986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10117F26986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift */; }; + AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573C27675B1E00FD64E2 /* Colorful */; }; + AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573F27678B3400FD64E2 /* UIImageColors */; }; AB19D619266E5C6700BA752A /* TTProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = AB19D618266E5C6700BA752A /* TTProgressHUD */; }; + AB1EF25427AFA19200F507D6 /* Heap.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1EF25327AFA19200F507D6 /* Heap.swift */; }; AB21CCA0274B4F0C00C115B1 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = AB21CC9F274B4F0C00C115B1 /* SwiftyBeaver */; }; + 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 */; }; + AB26F59B27AD125A00AB3468 /* Constant.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB26F59A27AD125A00AB3468 /* Constant.strings */; }; AB2CED64268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2CED63268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift */; }; + AB3072D2276D734800EFF242 /* SubSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3072D1276D734800EFF242 /* SubSection.swift */; }; + AB3072D4276E19AA00EFF242 /* FrontpageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3072D3276E19AA00EFF242 /* FrontpageView.swift */; }; AB30E34826D277F7007420BC /* Toplists.html in Resources */ = {isa = PBXBuildFile; fileRef = AB30E34726D277F7007420BC /* Toplists.html */; }; AB358311269D7B63009466A5 /* DFURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358310269D7B63009466A5 /* DFURLProtocol.swift */; }; AB358313269D7E89009466A5 /* DFRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358312269D7E89009466A5 /* DFRequest.swift */; }; @@ -29,100 +55,160 @@ AB3E9E7226D210B1008FE518 /* Popular.html in Resources */ = {isa = PBXBuildFile; fileRef = AB3E9E6A26D210B1008FE518 /* Popular.html */; }; AB3E9E7326D210B1008FE518 /* ParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3E9E6B26D210B1008FE518 /* ParserTests.swift */; }; AB3E9E7426D210B1008FE518 /* TestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3E9E6D26D210B1008FE518 /* TestHelper.swift */; }; - AB47FD9F25BC81A40007765D /* Normal@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FD9925BC81A40007765D /* Normal@3x.png */; }; - AB47FDA025BC81A40007765D /* Normal@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FD9A25BC81A40007765D /* Normal@2x.png */; }; - AB47FDA125BC81A40007765D /* Weird@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FD9B25BC81A40007765D /* Weird@3x.png */; }; - AB47FDA225BC81A40007765D /* Weird@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FD9C25BC81A40007765D /* Weird@2x.png */; }; - AB47FDAD25BC85060007765D /* Normal-ipad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FDAA25BC85060007765D /* Normal-ipad@2x.png */; }; - AB47FDAE25BC85060007765D /* Normal-ipad-pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FDAB25BC85060007765D /* Normal-ipad-pro@2x.png */; }; - AB47FDAF25BC85060007765D /* Normal-ipad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FDAC25BC85060007765D /* Normal-ipad.png */; }; - AB47FDB425BC859E0007765D /* Weird-ipad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FDB125BC859E0007765D /* Weird-ipad@2x.png */; }; - AB47FDB525BC859E0007765D /* Weird-ipad-pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FDB225BC859E0007765D /* Weird-ipad-pro@2x.png */; }; - AB47FDB625BC859E0007765D /* Weird-ipad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB47FDB325BC859E0007765D /* Weird-ipad.png */; }; AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */; }; + AB58A5AC2776B2BC00C0D285 /* AppDelegateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5AB2776B2BC00C0D285 /* AppDelegateStore.swift */; }; + AB58A5B22776B99000C0D285 /* AppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5B12776B99000C0D285 /* AppStore.swift */; }; AB5BE67926B95FDD007D4A55 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */; }; AB5BE68026B95FDD007D4A55 /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - AB60D0CF274C7AA000F899AB /* BetterCodable in Frameworks */ = {isa = PBXBuildFile; productRef = AB60D0CE274C7AA000F899AB /* BetterCodable */; }; AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB60D0E8274C7ECE00F899AB /* WaterfallGrid */; }; - AB60D0EB274CFB6D00F899AB /* SuggestionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB60D0EA274CFB6D00F899AB /* SuggestionProvider.swift */; }; 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 */; }; - AB73CEAF26AAC13F00EF6337 /* CodableExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB73CEAE26AAC13F00EF6337 /* CodableExtension.swift */; }; - AB7B29F226AC471E00EE1F14 /* MigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B29F126AC471E00EE1F14 /* MigrationPolicy.swift */; }; + AB706F7927890A6C0025A48A /* AppRouteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7827890A6C0025A48A /* AppRouteStore.swift */; }; + AB706F7B278937500025A48A /* FrontpageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7A278937500025A48A /* FrontpageStore.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 /* ToplistsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F852789AD490025A48A /* ToplistsStore.swift */; }; + AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F87278A4C8A0025A48A /* PopularView.swift */; }; + AB706F8A278A4CC50025A48A /* PopularStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F89278A4CC50025A48A /* PopularStore.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 /* WatchedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F91278A6E8C0025A48A /* WatchedStore.swift */; }; + AB706F95278A75D30025A48A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F94278A75D30025A48A /* HistoryView.swift */; }; + AB706F97278A77E20025A48A /* HistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F96278A77E20025A48A /* HistoryStore.swift */; }; + AB706F99278A820C0025A48A /* FiltersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F98278A820C0025A48A /* FiltersStore.swift */; }; + AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9A278AC5A30025A48A /* SearchRootView.swift */; }; + AB706F9D278ACCA20025A48A /* SearchRootStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9C278ACCA20025A48A /* SearchRootStore.swift */; }; + AB706F9F278AD4800025A48A /* GalleryHistoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */; }; + AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA0278BCEC60025A48A /* DetailView.swift */; }; + AB706FA3278BCF2F0025A48A /* DetailStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA2278BCF2F0025A48A /* DetailStore.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 */; }; + AB7BF2A627A6175E001865A3 /* AppIcon_Ukiyoe@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB7BF2A427A6175E001865A3 /* AppIcon_Ukiyoe@2x.png */; }; + AB7BF2A727A6175E001865A3 /* AppIcon_Ukiyoe@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB7BF2A527A6175E001865A3 /* AppIcon_Ukiyoe@3x.png */; }; + 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 /* TagCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2BF27A9669A001865A3 /* TagCategory.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 /* HapticUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D127AA3EDC001865A3 /* HapticUtil.swift */; }; + AB7BF2D427AA3F12001865A3 /* CookiesUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D327AA3F12001865A3 /* CookiesUtil.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 /* MigrationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FC27ABCAD4001865A3 /* MigrationStore.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 /* LogsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF42782DAB300E61E6A /* LogsStore.swift */; }; + AB86ABF72782DDE600E61E6A /* FileClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF62782DDE600E61E6A /* FileClient.swift */; }; + AB86ABF92782EC0D00E61E6A /* EhPandaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF82782EC0D00E61E6A /* EhPandaView.swift */; }; + AB86AC032782F76000E61E6A /* AppIcon_Default@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB86ABFD2782F76000E61E6A /* AppIcon_Default@3x.png */; }; + AB86AC072782F76000E61E6A /* AppIcon_Default@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB86AC012782F76000E61E6A /* AppIcon_Default@2x.png */; }; + AB86AC0A2782FAFA00E61E6A /* AppearanceSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC092782FAFA00E61E6A /* AppearanceSettingStore.swift */; }; + AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = AB86AC0F27831AD100E61E6A /* ComposableArchitecture */; }; + AB86AC1327856F2700E61E6A /* AppLockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC1227856F2700E61E6A /* AppLockStore.swift */; }; + AB86AC1A2785C2B300E61E6A /* HomeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC192785C2B300E61E6A /* HomeStore.swift */; }; AB8C821926BF801700E8C5E6 /* EhSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8C821826BF801700E8C5E6 /* EhSetting.swift */; }; ABA732D925A8018A00B3D9AB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732D825A8018A00B3D9AB /* Extensions.swift */; }; ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732DE25A852D800B3D9AB /* Filter.swift */; }; ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */ = {isa = PBXBuildFile; productRef = ABAC82FD26BC4A96009F5026 /* OpenCC */; }; ABAFFE4026A86E3000EE8661 /* MeasureTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABAFFE3F26A86E3000EE8661 /* MeasureTool.swift */; }; + ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2630278E6EF3007B6149 /* SearchView.swift */; }; + ABBB2633278E6F3B007B6149 /* SearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2632278E6F3B007B6149 /* SearchStore.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 /* PreviewsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263D2793C648007B6149 /* PreviewsStore.swift */; }; + ABBB2640279417EC007B6149 /* CommentsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263F279417EC007B6149 /* CommentsStore.swift */; }; + ABBB264227942B74007B6149 /* URLClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB264127942B74007B6149 /* URLClient.swift */; }; + ABBB266627977C2A007B6149 /* ArchivesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266527977C2A007B6149 /* ArchivesStore.swift */; }; + ABBB26682797BFAA007B6149 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26672797BFAA007B6149 /* ActivityView.swift */; }; + ABBB266A2797C61F007B6149 /* TorrentsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26692797C61F007B6149 /* TorrentsStore.swift */; }; + ABBB266C2797E882007B6149 /* ClipboardClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266B2797E882007B6149 /* ClipboardClient.swift */; }; + ABBB266E27998479007B6149 /* QuickSearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266D27998479007B6149 /* QuickSearchStore.swift */; }; + ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */; }; + ABBB2673279B9332007B6149 /* ReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2672279B9332007B6149 /* ReadingView.swift */; }; + ABBB2675279B933D007B6149 /* ReadingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2674279B933D007B6149 /* ReadingStore.swift */; }; + ABBB2677279CDBB0007B6149 /* ImageClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2676279CDBB0007B6149 /* ImageClient.swift */; }; + ABBB2679279D454C007B6149 /* GalleryInfosStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2678279D454C007B6149 /* GalleryInfosStore.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 */; }; - ABC1FAB6264152C800A9F352 /* StoreAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC1FAB5264152C800A9F352 /* StoreAccessor.swift */; }; ABC1FAB82642C37D00A9F352 /* NewDawnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */; }; ABC3C7852593699B00E0C11B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ABC3C7692593699A00E0C11B /* Assets.xcassets */; }; - ABC3C7862593699B00E0C11B /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C76A2593699A00E0C11B /* Utilities.swift */; }; 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 */; }; - ABC3C7962593699B00E0C11B /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C7802593699A00E0C11B /* Models.swift */; }; ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = ABC4A0782751B40E00968A4F /* Kingfisher */; }; ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = ABC681F126898D46007BBD69 /* Model.xcdatamodeld */; }; + ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355C27B118330091DCDB /* DetailSearchView.swift */; }; + ABC8355F27B118370091DCDB /* DetailSearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355E27B118370091DCDB /* DetailSearchStore.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 */; }; - ABCA93C42692A0BF00A98BC6 /* PersistenceAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93C32692A0BF00A98BC6 /* PersistenceAccessor.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 /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4032726B7967F00001B8C /* Category.swift */; }; + ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4032726B7967F00001B8C /* CategoryView.swift */; }; + ABD49D5A277C5356003D1A07 /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D59277C5356003D1A07 /* FavoritesStore.swift */; }; + ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */; }; + ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D5F277C7722003D1A07 /* TabBarView.swift */; }; + ABD49D64277C7AD5003D1A07 /* TabBarStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D63277C7AD5003D1A07 /* TabBarStore.swift */; }; + ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D66277EAC90003D1A07 /* URLUtil.swift */; }; + ABD49D6A277EEF73003D1A07 /* SettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D69277EEF73003D1A07 /* SettingStore.swift */; }; ABD5FDD4263D05110021A4C6 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */; }; ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = ABD7005826B1C31500DC59C9 /* Kanna */; }; + ABD970B427A2A39E001693B0 /* R.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD970B327A2A39E001693B0 /* R.generated.swift */; }; + ABD970B727A2A6BD001693B0 /* Rswift in Frameworks */ = {isa = PBXBuildFile; productRef = ABD970B627A2A6BD001693B0 /* Rswift */; }; ABE1867826A1733000689FDC /* LaboratorySettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE1867726A1733000689FDC /* LaboratorySettingView.swift */; }; - ABE2AE752699F238001D47AA /* AppEnvStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE2AE742699F238001D47AA /* AppEnvStorage.swift */; }; ABE9401526FF158D0085E158 /* QuickSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE9401426FF158D0085E158 /* QuickSearchView.swift */; }; ABE9402D26FF89220085E158 /* AlertKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABE9402C26FF89220085E158 /* AlertKit */; }; 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 */; }; - ABF45AB925F3312F00ECB568 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AB425F3312F00ECB568 /* AppState.swift */; }; - ABF45ABA25F3312F00ECB568 /* AppCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AB525F3312F00ECB568 /* AppCommand.swift */; }; ABF45ABB25F3312F00ECB568 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AB625F3312F00ECB568 /* AppError.swift */; }; - ABF45ABC25F3312F00ECB568 /* AppAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AB725F3312F00ECB568 /* AppAction.swift */; }; - ABF45ABD25F3312F00ECB568 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AB825F3312F00ECB568 /* Store.swift */; }; - ABF45ADF25F3313D00ECB568 /* FilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC125F3313D00ECB568 /* FilterView.swift */; }; - ABF45AE025F3313D00ECB568 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC225F3313D00ECB568 /* HomeView.swift */; }; - ABF45AE125F3313D00ECB568 /* Home.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC325F3313D00ECB568 /* Home.swift */; }; - ABF45AE225F3313D00ECB568 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC425F3313D00ECB568 /* AuthView.swift */; }; - ABF45AE325F3313D00ECB568 /* SlideMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC525F3313D00ECB568 /* SlideMenu.swift */; }; + ABF45ADF25F3313D00ECB568 /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC125F3313D00ECB568 /* FiltersView.swift */; }; ABF45AE425F3313D00ECB568 /* TagCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC725F3313D00ECB568 /* TagCloudView.swift */; }; - ABF45AE525F3313D00ECB568 /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC825F3313D00ECB568 /* Comment.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 */; }; - ABF45AEC25F3313D00ECB568 /* ReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD025F3313D00ECB568 /* ReadingView.swift */; }; - ABF45AED25F3313D00ECB568 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD225F3313D00ECB568 /* DetailView.swift */; }; - ABF45AEE25F3313D00ECB568 /* ArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD325F3313D00ECB568 /* ArchiveView.swift */; }; + ABF45AEE25F3313D00ECB568 /* ArchivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD325F3313D00ECB568 /* ArchivesView.swift */; }; ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD425F3313D00ECB568 /* TorrentsView.swift */; }; - ABF45AF025F3313D00ECB568 /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD525F3313D00ECB568 /* CommentView.swift */; }; - ABF45AF125F3313D00ECB568 /* AssociatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD625F3313D00ECB568 /* AssociatedView.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 */; }; - ABF45AF825F3313D00ECB568 /* EhPandaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADE25F3313D00ECB568 /* EhPandaView.swift */; }; ABF75F3F25A19CD200544D29 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF75F3E25A19CD200544D29 /* User.swift */; }; - ABF971FF26DD394200118887 /* ImageSaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF971FE26DD394200118887 /* ImageSaver.swift */; }; ABF9720A26DE6E1300118887 /* GalleryDetailWithGreeting.html in Resources */ = {isa = PBXBuildFile; fileRef = ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */; }; /* End PBXBuildFile section */ @@ -158,13 +244,36 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AB0929B5277F043D00F107CA /* AccountSettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingStore.swift; sourceTree = ""; }; + AB0929BD2780032400F107CA /* EhSettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingStore.swift; sourceTree = ""; }; + AB0929BF27805A8200F107CA /* LoginStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginStore.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 /* CookiesClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookiesClient.swift; sourceTree = ""; }; + AB0929CB2781A0B000F107CA /* HapticClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticClient.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 /* GeneralSettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingStore.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 = ""; }; 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 = ""; }; + 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 = ""; }; + AB26F59A27AD125A00AB3468 /* Constant.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Constant.strings; 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 = ""; }; AB30E34726D277F7007420BC /* Toplists.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Toplists.html; 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 = ""; }; @@ -179,34 +288,84 @@ AB3E9E6A26D210B1008FE518 /* Popular.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Popular.html; sourceTree = ""; }; AB3E9E6B26D210B1008FE518 /* ParserTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParserTests.swift; sourceTree = ""; }; AB3E9E6D26D210B1008FE518 /* TestHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHelper.swift; sourceTree = ""; }; - AB47FD9925BC81A40007765D /* Normal@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Normal@3x.png"; sourceTree = ""; }; - AB47FD9A25BC81A40007765D /* Normal@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Normal@2x.png"; sourceTree = ""; }; - AB47FD9B25BC81A40007765D /* Weird@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Weird@3x.png"; sourceTree = ""; }; - AB47FD9C25BC81A40007765D /* Weird@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Weird@2x.png"; sourceTree = ""; }; - AB47FDAA25BC85060007765D /* Normal-ipad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Normal-ipad@2x.png"; sourceTree = ""; }; - AB47FDAB25BC85060007765D /* Normal-ipad-pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Normal-ipad-pro@2x.png"; sourceTree = ""; }; - AB47FDAC25BC85060007765D /* Normal-ipad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Normal-ipad.png"; sourceTree = ""; }; - AB47FDB125BC859E0007765D /* Weird-ipad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Weird-ipad@2x.png"; sourceTree = ""; }; - AB47FDB225BC859E0007765D /* Weird-ipad-pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Weird-ipad-pro@2x.png"; sourceTree = ""; }; - AB47FDB325BC859E0007765D /* Weird-ipad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Weird-ipad.png"; 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 /* AppDelegateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateStore.swift; sourceTree = ""; }; + AB58A5B12776B99000C0D285 /* AppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStore.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 = ""; }; - AB60D0EA274CFB6D00F899AB /* SuggestionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionProvider.swift; 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 = ""; }; - AB73CEAE26AAC13F00EF6337 /* CodableExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableExtension.swift; sourceTree = ""; }; - AB7B29F126AC471E00EE1F14 /* MigrationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationPolicy.swift; sourceTree = ""; }; + AB706F7827890A6C0025A48A /* AppRouteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteStore.swift; sourceTree = ""; }; + AB706F7A278937500025A48A /* FrontpageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontpageStore.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 /* ToplistsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsStore.swift; sourceTree = ""; }; + AB706F87278A4C8A0025A48A /* PopularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularView.swift; sourceTree = ""; }; + AB706F89278A4CC50025A48A /* PopularStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularStore.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 /* WatchedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedStore.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 /* HistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryStore.swift; sourceTree = ""; }; + AB706F98278A820C0025A48A /* FiltersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersStore.swift; sourceTree = ""; }; + AB706F9A278AC5A30025A48A /* SearchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootView.swift; sourceTree = ""; }; + AB706F9C278ACCA20025A48A /* SearchRootStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootStore.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 /* DetailStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailStore.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 = ""; }; + AB7BF2A427A6175E001865A3 /* AppIcon_Ukiyoe@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe@2x.png"; sourceTree = ""; }; + AB7BF2A527A6175E001865A3 /* AppIcon_Ukiyoe@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe@3x.png"; 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 /* TagCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCategory.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 /* HapticUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticUtil.swift; sourceTree = ""; }; + AB7BF2D327AA3F12001865A3 /* CookiesUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookiesUtil.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 /* MigrationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationStore.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 /* LogsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsStore.swift; sourceTree = ""; }; + AB86ABF62782DDE600E61E6A /* FileClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileClient.swift; sourceTree = ""; }; + AB86ABF82782EC0D00E61E6A /* EhPandaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EhPandaView.swift; sourceTree = ""; }; + AB86ABFD2782F76000E61E6A /* AppIcon_Default@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default@3x.png"; sourceTree = ""; }; + AB86AC012782F76000E61E6A /* AppIcon_Default@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default@2x.png"; sourceTree = ""; }; + AB86AC092782FAFA00E61E6A /* AppearanceSettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingStore.swift; sourceTree = ""; }; + AB86AC1227856F2700E61E6A /* AppLockStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockStore.swift; sourceTree = ""; }; + AB86AC192785C2B300E61E6A /* HomeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeStore.swift; sourceTree = ""; }; AB8C821826BF801700E8C5E6 /* EhSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSetting.swift; 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 = ""; }; @@ -214,33 +373,54 @@ ABAFFE3F26A86E3000EE8661 /* MeasureTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasureTool.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 = ""; }; + ABBB2632278E6F3B007B6149 /* SearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStore.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 /* PreviewsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewsStore.swift; sourceTree = ""; }; + ABBB263F279417EC007B6149 /* CommentsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsStore.swift; sourceTree = ""; }; + ABBB264127942B74007B6149 /* URLClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLClient.swift; sourceTree = ""; }; + ABBB266527977C2A007B6149 /* ArchivesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivesStore.swift; sourceTree = ""; }; + ABBB26672797BFAA007B6149 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; + ABBB26692797C61F007B6149 /* TorrentsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentsStore.swift; sourceTree = ""; }; + ABBB266B2797E882007B6149 /* ClipboardClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardClient.swift; sourceTree = ""; }; + ABBB266D27998479007B6149 /* QuickSearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchStore.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 /* ReadingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingStore.swift; sourceTree = ""; }; + ABBB2676279CDBB0007B6149 /* ImageClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageClient.swift; sourceTree = ""; }; + ABBB2678279D454C007B6149 /* GalleryInfosStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryInfosStore.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 = ""; }; - ABC1FAB5264152C800A9F352 /* StoreAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreAccessor.swift; 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 = ""; }; - ABC3C76A2593699A00E0C11B /* Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utilities.swift; 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 = ""; }; - ABC3C7802593699A00E0C11B /* Models.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Models.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 = ""; }; + ABC8355C27B118330091DCDB /* DetailSearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchView.swift; sourceTree = ""; }; + ABC8355E27B118370091DCDB /* DetailSearchStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchStore.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 = ""; }; - ABCA93C32692A0BF00A98BC6 /* PersistenceAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceAccessor.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 /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; }; + ABD4032726B7967F00001B8C /* CategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryView.swift; sourceTree = ""; }; + ABD49D59277C5356003D1A07 /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = ""; }; + ABD49D5F277C7722003D1A07 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; + ABD49D63277C7AD5003D1A07 /* TabBarStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarStore.swift; sourceTree = ""; }; + ABD49D66277EAC90003D1A07 /* URLUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLUtil.swift; sourceTree = ""; }; + ABD49D69277EEF73003D1A07 /* SettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingStore.swift; sourceTree = ""; }; ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = SOURCE_ROOT; }; + ABD970B327A2A39E001693B0 /* R.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = R.generated.swift; path = App/R.generated.swift; sourceTree = ""; }; ABE1867726A1733000689FDC /* LaboratorySettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaboratorySettingView.swift; sourceTree = ""; }; - ABE2AE742699F238001D47AA /* AppEnvStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvStorage.swift; sourceTree = ""; }; ABE9376C265DCD9400EA8B30 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; ABE9376D265DCD9400EA8B30 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; ABE9401426FF158D0085E158 /* QuickSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchView.swift; sourceTree = ""; }; @@ -250,39 +430,26 @@ 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 = ""; }; - ABF45AB425F3312F00ECB568 /* AppState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; - ABF45AB525F3312F00ECB568 /* AppCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppCommand.swift; sourceTree = ""; }; ABF45AB625F3312F00ECB568 /* AppError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; - ABF45AB725F3312F00ECB568 /* AppAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppAction.swift; sourceTree = ""; }; - ABF45AB825F3312F00ECB568 /* Store.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; - ABF45AC125F3313D00ECB568 /* FilterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterView.swift; sourceTree = ""; }; - ABF45AC225F3313D00ECB568 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; - ABF45AC325F3313D00ECB568 /* Home.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Home.swift; sourceTree = ""; }; - ABF45AC425F3313D00ECB568 /* AuthView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; - ABF45AC525F3313D00ECB568 /* SlideMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SlideMenu.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 /* Comment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Comment.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 = ""; }; - ABF45AD025F3313D00ECB568 /* ReadingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReadingView.swift; sourceTree = ""; }; - ABF45AD225F3313D00ECB568 /* DetailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; - ABF45AD325F3313D00ECB568 /* ArchiveView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArchiveView.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 /* CommentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentView.swift; sourceTree = ""; }; - ABF45AD625F3313D00ECB568 /* AssociatedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociatedView.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 = ""; }; - ABF45ADE25F3313D00ECB568 /* EhPandaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EhPandaView.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 = ""; }; - ABF971FE26DD394200118887 /* ImageSaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaver.swift; sourceTree = ""; }; ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryDetailWithGreeting.html; sourceTree = ""; }; /* End PBXFileReference section */ @@ -298,16 +465,22 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + ABD970B727A2A6BD001693B0 /* Rswift in Frameworks */, + AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */, ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */, AB19D619266E5C6700BA752A /* TTProgressHUD in Frameworks */, ABE9402D26FF89220085E158 /* AlertKit in Frameworks */, AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */, ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */, AB0F68AF26A6D92F00AC3A54 /* DeprecatedAPI in Frameworks */, - AB60D0CF274C7AA000F899AB /* BetterCodable in Frameworks */, + AB26F59927ACDB4200AB3468 /* FilePicker in Frameworks */, AB6505A026B0027800F91E9D /* SwiftUIPager in Frameworks */, AB21CCA0274B4F0C00C115B1 /* SwiftyBeaver in Frameworks */, + ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */, ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */, + AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */, + ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */, + AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -321,6 +494,114 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + AB0929BA277F1B7400F107CA /* DataFlow */ = { + isa = PBXGroup; + children = ( + ABD49D69277EEF73003D1A07 /* SettingStore.swift */, + AB0929B5277F043D00F107CA /* AccountSettingStore.swift */, + AB0929D52782A65F00F107CA /* GeneralSettingStore.swift */, + AB86AC092782FAFA00E61E6A /* AppearanceSettingStore.swift */, + AB0929BD2780032400F107CA /* EhSettingStore.swift */, + AB0929BF27805A8200F107CA /* LoginStore.swift */, + AB86ABF42782DAB300E61E6A /* LogsStore.swift */, + ); + path = DataFlow; + sourceTree = ""; + }; + AB0929C12781589000F107CA /* Clients */ = { + isa = PBXGroup; + children = ( + AB0929C72781938A00F107CA /* DFClient.swift */, + AB86ABF62782DDE600E61E6A /* FileClient.swift */, + ABBB264127942B74007B6149 /* URLClient.swift */, + ABBB2676279CDBB0007B6149 /* ImageClient.swift */, + AB706F8D278A5DCF0025A48A /* DeviceClient.swift */, + AB0929D12781E7D500F107CA /* LoggerClient.swift */, + AB0929CB2781A0B000F107CA /* HapticClient.swift */, + AB0929C5278160AE00F107CA /* LibraryClient.swift */, + AB0929C9278196ED00F107CA /* CookiesClient.swift */, + AB0929CD2781AADA00F107CA /* DatabaseClient.swift */, + ABBB266B2797E882007B6149 /* ClipboardClient.swift */, + AB706F8F278A5F680025A48A /* AppDelegateClient.swift */, + AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */, + AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */, + AB0929D72782A83A00F107CA /* AuthorizationClient.swift */, + ); + path = Clients; + sourceTree = ""; + }; + AB24C55D276756A40085C33A /* Support */ = { + isa = PBXGroup; + children = ( + ABF45AC125F3313D00ECB568 /* FiltersView.swift */, + AB706F98278A820C0025A48A /* FiltersStore.swift */, + ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */, + ABF45AC625F3313D00ECB568 /* Components */, + ); + path = Support; + sourceTree = ""; + }; + AB24C55F276757240085C33A /* Favorites */ = { + isa = PBXGroup; + children = ( + AB24C55927674EDF0085C33A /* FavoritesView.swift */, + ABD49D59277C5356003D1A07 /* FavoritesStore.swift */, + ); + path = Favorites; + sourceTree = ""; + }; + AB24C560276757940085C33A /* Support */ = { + isa = PBXGroup; + children = ( + ABF45ADA25F3313D00ECB568 /* WebView.swift */, + AB6DE896268822390087C579 /* LogsView.swift */, + ABBC332726BE31AE0084A331 /* EhSettingView.swift */, + AB0ABCB426C5406400AD970F /* LoginView.swift */, + ); + path = Support; + sourceTree = ""; + }; + AB24C561276757A30085C33A /* Support */ = { + isa = PBXGroup; + children = ( + AB69CB8126B3DAF400699359 /* ControlPanel.swift */, + AB69CB7F26B3DABC00699359 /* AdvancedList.swift */, + ABAFFE3F26A86E3000EE8661 /* MeasureTool.swift */, + ); + path = Support; + sourceTree = ""; + }; + AB24C562276757B00085C33A /* Support */ = { + isa = PBXGroup; + children = ( + ABF45AC825F3313D00ECB568 /* PostCommentView.swift */, + ABF45ACB25F3313D00ECB568 /* LinkedText.swift */, + ABF45ACA25F3313D00ECB568 /* RatingView.swift */, + ); + path = Support; + sourceTree = ""; + }; + AB24C563276757C30085C33A /* Support */ = { + isa = PBXGroup; + children = ( + ABE9401426FF158D0085E158 /* QuickSearchView.swift */, + ABBB266D27998479007B6149 /* QuickSearchStore.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 = ""; + }; AB3E9E6126D210B1008FE518 /* EhPandaTests */ = { isa = PBXGroup; children = ( @@ -381,30 +662,26 @@ AB40CFE52598423E00D1DC9A /* Tools */ = { isa = PBXGroup; children = ( + AB706F7E278981210025A48A /* Extensions */, + AB0929C12781589000F107CA /* Clients */, + ABD49D65277EAC7E003D1A07 /* Utilities */, ABCD2F0D25976B95008E5A20 /* Parser.swift */, - ABE2AE742699F238001D47AA /* AppEnvStorage.swift */, + ABC3C76D2593699A00E0C11B /* Defaults.swift */, AB38A0CA25CA993D00764D64 /* ColorCodable.swift */, - AB73CEAE26AAC13F00EF6337 /* CodableExtension.swift */, - ABF971FE26DD394200118887 /* ImageSaver.swift */, + ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */, ); path = Tools; sourceTree = ""; }; - AB47FDA625BC823F0007765D /* AltIcons */ = { + AB47FDA625BC823F0007765D /* Icons */ = { isa = PBXGroup; children = ( - AB47FD9A25BC81A40007765D /* Normal@2x.png */, - AB47FD9925BC81A40007765D /* Normal@3x.png */, - AB47FDAC25BC85060007765D /* Normal-ipad.png */, - AB47FDAA25BC85060007765D /* Normal-ipad@2x.png */, - AB47FDAB25BC85060007765D /* Normal-ipad-pro@2x.png */, - AB47FD9C25BC81A40007765D /* Weird@2x.png */, - AB47FD9B25BC81A40007765D /* Weird@3x.png */, - AB47FDB325BC859E0007765D /* Weird-ipad.png */, - AB47FDB125BC859E0007765D /* Weird-ipad@2x.png */, - AB47FDB225BC859E0007765D /* Weird-ipad-pro@2x.png */, - ); - path = AltIcons; + AB86AC012782F76000E61E6A /* AppIcon_Default@2x.png */, + AB86ABFD2782F76000E61E6A /* AppIcon_Default@3x.png */, + AB7BF2A427A6175E001865A3 /* AppIcon_Ukiyoe@2x.png */, + AB7BF2A527A6175E001865A3 /* AppIcon_Ukiyoe@3x.png */, + ); + path = Icons; sourceTree = ""; }; AB5BE67726B95FDD007D4A55 /* ShareExtension */ = { @@ -416,6 +693,32 @@ path = ShareExtension; sourceTree = ""; }; + AB706F7D278937A70025A48A /* DataFlow */ = { + isa = PBXGroup; + children = ( + AB86AC192785C2B300E61E6A /* HomeStore.swift */, + AB706F7A278937500025A48A /* FrontpageStore.swift */, + AB706F852789AD490025A48A /* ToplistsStore.swift */, + AB706F89278A4CC50025A48A /* PopularStore.swift */, + AB706F91278A6E8C0025A48A /* WatchedStore.swift */, + AB706F96278A77E20025A48A /* HistoryStore.swift */, + ); + path = DataFlow; + 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 = ( @@ -434,19 +737,124 @@ AB7B29F426AC475300EE1F14 /* Migration */ = { isa = PBXGroup; children = ( - AB7B29F126AC471E00EE1F14 /* MigrationPolicy.swift */, + 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 */, + AB7BF2BF27A9669A001865A3 /* TagCategory.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 */, + AB26F59327ACC6CD00AB3468 /* TagTranslator.swift */, + ); + path = Persistent; + sourceTree = ""; + }; + AB7BF2BE27A96674001865A3 /* Support */ = { + isa = PBXGroup; + children = ( + ABF313A425B1AB6600D47A2F /* Misc.swift */, + ABF45AB625F3312F00ECB568 /* AppError.swift */, + AB8C821826BF801700E8C5E6 /* EhSetting.swift */, + AB7BF2AA27A642FB001865A3 /* BrowsingCountry.swift */, + AB7BF2C527A968AB001865A3 /* TranslatableLanguage.swift */, + ); + path = Support; + sourceTree = ""; + }; + AB7BF2F927ABCA20001865A3 /* Migration */ = { + isa = PBXGroup; + children = ( + AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */, + AB7BF2FC27ABCAD4001865A3 /* MigrationStore.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 */, - ABCA93C32692A0BF00A98BC6 /* PersistenceAccessor.swift */, ); path = Database; sourceTree = ""; @@ -458,6 +866,32 @@ name = Frameworks; sourceTree = ""; }; + AB86AC112783226100E61E6A /* Search */ = { + isa = PBXGroup; + children = ( + AB706F9A278AC5A30025A48A /* SearchRootView.swift */, + AB706F9C278ACCA20025A48A /* SearchRootStore.swift */, + ABBB2630278E6EF3007B6149 /* SearchView.swift */, + ABBB2632278E6F3B007B6149 /* SearchStore.swift */, + AB24C563276757C30085C33A /* Support */, + ); + path = Search; + sourceTree = ""; + }; + ABBB263C2793C580007B6149 /* DataFlow */ = { + isa = PBXGroup; + children = ( + AB706FA2278BCF2F0025A48A /* DetailStore.swift */, + ABC8355E27B118370091DCDB /* DetailSearchStore.swift */, + ABBB266527977C2A007B6149 /* ArchivesStore.swift */, + ABBB26692797C61F007B6149 /* TorrentsStore.swift */, + ABBB263D2793C648007B6149 /* PreviewsStore.swift */, + ABBB263F279417EC007B6149 /* CommentsStore.swift */, + ABBB2678279D454C007B6149 /* GalleryInfosStore.swift */, + ); + path = DataFlow; + sourceTree = ""; + }; ABC3C74B2593696C00E0C11B = { isa = PBXGroup; children = ( @@ -483,6 +917,7 @@ isa = PBXGroup; children = ( ABC3C7682593699A00E0C11B /* App */, + ABD970B327A2A39E001693B0 /* R.generated.swift */, ABF45AB325F3312F00ECB568 /* DataFlow */, ABC3C77B2593699A00E0C11B /* Network */, ABC3C77F2593699A00E0C11B /* Models */, @@ -498,16 +933,13 @@ isa = PBXGroup; children = ( ABC3C76B2593699A00E0C11B /* EhPandaApp.swift */, - ABC3C76A2593699A00E0C11B /* Utilities.swift */, - ABC3C76D2593699A00E0C11B /* Defaults.swift */, - ABA732D825A8018A00B3D9AB /* Extensions.swift */, - ABC3C7762593699A00E0C11B /* ViewModifiers.swift */, AB40CFE52598423E00D1DC9A /* Tools */, ABEE0AFC2595C6F800C997AE /* Localizable.strings */, ABC3C7692593699A00E0C11B /* Assets.xcassets */, - AB47FDA625BC823F0007765D /* AltIcons */, + AB47FDA625BC823F0007765D /* Icons */, ABC3C76E2593699A00E0C11B /* Info.plist */, AB7E6B3225D24FE00035CC68 /* InfoPlist.strings */, + AB26F59A27AD125A00AB3468 /* Constant.strings */, ); path = App; sourceTree = ""; @@ -528,25 +960,44 @@ ABC3C77F2593699A00E0C11B /* Models */ = { isa = PBXGroup; children = ( - ABF75F3E25A19CD200544D29 /* User.swift */, - ABA732DE25A852D800B3D9AB /* Filter.swift */, - ABC3C7802593699A00E0C11B /* Models.swift */, - ABEA1FE525A9B40B002966B9 /* Setting.swift */, - AB8C821826BF801700E8C5E6 /* EhSetting.swift */, - ABF313A425B1AB6600D47A2F /* Misc.swift */, + AB7BF2B827A96559001865A3 /* Gallery */, + AB7BF2BD27A9663E001865A3 /* Persistent */, + AB7BF2BE27A96674001865A3 /* Support */, ); path = Models; sourceTree = ""; }; + ABD49D5E277C7715003D1A07 /* TabBar */ = { + isa = PBXGroup; + children = ( + ABD49D5F277C7722003D1A07 /* TabBarView.swift */, + ABD49D63277C7AD5003D1A07 /* TabBarStore.swift */, + ); + path = TabBar; + sourceTree = ""; + }; + ABD49D65277EAC7E003D1A07 /* Utilities */ = { + isa = PBXGroup; + children = ( + ABD49D66277EAC90003D1A07 /* URLUtil.swift */, + AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */, + AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */, + AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */, + AB7BF2D127AA3EDC001865A3 /* HapticUtil.swift */, + AB7BF2D327AA3F12001865A3 /* CookiesUtil.swift */, + AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */, + ); + path = Utilities; + sourceTree = ""; + }; ABF45AB325F3312F00ECB568 /* DataFlow */ = { isa = PBXGroup; children = ( - ABF45AB825F3312F00ECB568 /* Store.swift */, - ABC1FAB5264152C800A9F352 /* StoreAccessor.swift */, - ABF45AB425F3312F00ECB568 /* AppState.swift */, - ABF45AB725F3312F00ECB568 /* AppAction.swift */, - ABF45AB525F3312F00ECB568 /* AppCommand.swift */, - ABF45AB625F3312F00ECB568 /* AppError.swift */, + AB1EF25327AFA19200F507D6 /* Heap.swift */, + AB58A5B12776B99000C0D285 /* AppStore.swift */, + AB86AC1227856F2700E61E6A /* AppLockStore.swift */, + AB706F7827890A6C0025A48A /* AppRouteStore.swift */, + AB58A5AB2776B2BC00C0D285 /* AppDelegateStore.swift */, ); path = DataFlow; sourceTree = ""; @@ -554,11 +1005,15 @@ ABF45ABF25F3313D00ECB568 /* View */ = { isa = PBXGroup; children = ( + AB7BF2F927ABCA20001865A3 /* Migration */, + ABD49D5E277C7715003D1A07 /* TabBar */, ABF45AC025F3313D00ECB568 /* Home */, + AB24C55F276757240085C33A /* Favorites */, + AB86AC112783226100E61E6A /* Search */, ABF45AD125F3313D00ECB568 /* Detail */, ABF45ACF25F3313D00ECB568 /* Reading */, ABF45AD725F3313D00ECB568 /* Setting */, - ABF45AC625F3313D00ECB568 /* Tools */, + AB24C55D276756A40085C33A /* Support */, ); path = View; sourceTree = ""; @@ -566,41 +1021,41 @@ ABF45AC025F3313D00ECB568 /* Home */ = { isa = PBXGroup; children = ( - ABF45AC325F3313D00ECB568 /* Home.swift */, - ABF45AC525F3313D00ECB568 /* SlideMenu.swift */, - ABF45AC225F3313D00ECB568 /* HomeView.swift */, - ABF45AC125F3313D00ECB568 /* FilterView.swift */, - ABF45AC425F3313D00ECB568 /* AuthView.swift */, - ABE9401426FF158D0085E158 /* QuickSearchView.swift */, + AB24C55B2767565A0085C33A /* HomeView.swift */, + AB3072D3276E19AA00EFF242 /* FrontpageView.swift */, + AB706F832789AD2D0025A48A /* ToplistsView.swift */, + AB706F87278A4C8A0025A48A /* PopularView.swift */, + AB706F8B278A4F6C0025A48A /* WatchedView.swift */, + AB706F94278A75D30025A48A /* HistoryView.swift */, + AB706F7D278937A70025A48A /* DataFlow */, ); path = Home; sourceTree = ""; }; - ABF45AC625F3313D00ECB568 /* Tools */ = { + ABF45AC625F3313D00ECB568 /* Components */ = { isa = PBXGroup; children = ( + AB24C564276758D00085C33A /* Cells */, AB7B29F526AC741600EE1F14 /* GenericList.swift */, - ABF45ACE25F3313D00ECB568 /* GalleryDetailCell.swift */, - ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */, - ABF45ACC25F3313D00ECB568 /* AlertView.swift */, - ABD4032726B7967F00001B8C /* Category.swift */, - ABF45AC825F3313D00ECB568 /* Comment.swift */, + ABD4032726B7967F00001B8C /* CategoryView.swift */, ABF45ACD25F3313D00ECB568 /* Placeholder.swift */, ABF45AC725F3313D00ECB568 /* TagCloudView.swift */, ABBC332926BE7C940084A331 /* SettingTextField.swift */, + ABF45ACC25F3313D00ECB568 /* AlertView.swift */, AB0ABCB626C541A400AD970F /* WaveForm.swift */, - AB60D0EA274CFB6D00F899AB /* SuggestionProvider.swift */, + AB3072D1276D734800EFF242 /* SubSection.swift */, + AB706F81278986120025A48A /* ToolbarItems.swift */, + ABBB26672797BFAA007B6149 /* ActivityView.swift */, ); - path = Tools; + path = Components; sourceTree = ""; }; ABF45ACF25F3313D00ECB568 /* Reading */ = { isa = PBXGroup; children = ( - ABF45AD025F3313D00ECB568 /* ReadingView.swift */, - AB69CB8126B3DAF400699359 /* ControlPanel.swift */, - AB69CB7F26B3DABC00699359 /* AdvancedList.swift */, - ABAFFE3F26A86E3000EE8661 /* MeasureTool.swift */, + ABBB2672279B9332007B6149 /* ReadingView.swift */, + ABBB2674279B933D007B6149 /* ReadingStore.swift */, + AB24C561276757A30085C33A /* Support */, ); path = Reading; sourceTree = ""; @@ -608,14 +1063,15 @@ ABF45AD125F3313D00ECB568 /* Detail */ = { isa = PBXGroup; children = ( - ABF45AD225F3313D00ECB568 /* DetailView.swift */, - ABF45AD325F3313D00ECB568 /* ArchiveView.swift */, + AB706FA0278BCEC60025A48A /* DetailView.swift */, + ABC8355C27B118330091DCDB /* DetailSearchView.swift */, + ABF45AD325F3313D00ECB568 /* ArchivesView.swift */, ABF45AD425F3313D00ECB568 /* TorrentsView.swift */, - ABF45AD525F3313D00ECB568 /* CommentView.swift */, - ABF45AD625F3313D00ECB568 /* AssociatedView.swift */, - ABF45ACA25F3313D00ECB568 /* RatingView.swift */, - ABF45ACB25F3313D00ECB568 /* LinkedText.swift */, + AB706FA4278C3DDE0025A48A /* PreviewsView.swift */, + ABF45AD525F3313D00ECB568 /* CommentsView.swift */, ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */, + ABBB263C2793C580007B6149 /* DataFlow */, + AB24C562276757B00085C33A /* Support */, ); path = Detail; sourceTree = ""; @@ -629,12 +1085,9 @@ ABF45ADC25F3313D00ECB568 /* AppearanceSettingView.swift */, ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */, ABE1867726A1733000689FDC /* LaboratorySettingView.swift */, - ABF45ADE25F3313D00ECB568 /* EhPandaView.swift */, - ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */, - ABF45ADA25F3313D00ECB568 /* WebView.swift */, - AB6DE896268822390087C579 /* LogsView.swift */, - ABBC332726BE31AE0084A331 /* EhSettingView.swift */, - AB0ABCB426C5406400AD970F /* LoginView.swift */, + AB86ABF82782EC0D00E61E6A /* EhPandaView.swift */, + AB0929BA277F1B7400F107CA /* DataFlow */, + AB24C560276757940085C33A /* Support */, ); path = Setting; sourceTree = ""; @@ -663,11 +1116,12 @@ isa = PBXNativeTarget; buildConfigurationList = ABC3C7632593696E00E0C11B /* Build configuration list for PBXNativeTarget "EhPanda" */; buildPhases = ( + AB2E936227A24E0A00EA99F1 /* R.Swift */, + AB69FE41263C328400716FBD /* SwiftLint */, ABC3C7502593696C00E0C11B /* Sources */, ABC3C7512593696C00E0C11B /* Frameworks */, ABC3C7522593696C00E0C11B /* Resources */, AB5BE68126B95FDD007D4A55 /* Embed App Extensions */, - AB69FE41263C328400716FBD /* SwiftLint */, ); buildRules = ( ); @@ -683,9 +1137,15 @@ ABAC82FD26BC4A96009F5026 /* OpenCC */, ABE9402C26FF89220085E158 /* AlertKit */, AB21CC9F274B4F0C00C115B1 /* SwiftyBeaver */, - AB60D0CE274C7AA000F899AB /* BetterCodable */, AB60D0E8274C7ECE00F899AB /* WaterfallGrid */, ABC4A0782751B40E00968A4F /* Kingfisher */, + AB17573C27675B1E00FD64E2 /* Colorful */, + AB17573F27678B3400FD64E2 /* UIImageColors */, + ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */, + AB86AC0F27831AD100E61E6A /* ComposableArchitecture */, + ABBB2635278FB888007B6149 /* SwiftUINavigation */, + ABD970B627A2A6BD001693B0 /* Rswift */, + AB26F59827ACDB4200AB3468 /* FilePicker */, ); productName = EhPanda; productReference = ABC3C7542593696C00E0C11B /* EhPanda.app */; @@ -731,8 +1191,8 @@ }; }; buildConfigurationList = ABC3C74F2593696C00E0C11B /* Build configuration list for PBXProject "EhPanda" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = ja; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, @@ -752,9 +1212,15 @@ ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */, ABE9402B26FF89220085E158 /* XCRemoteSwiftPackageReference "AlertKit" */, AB21CC9E274B4F0C00C115B1 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, - AB60D0CD274C7AA000F899AB /* XCRemoteSwiftPackageReference "BetterCodable" */, AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */, ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */, + AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */, + AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */, + ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, + AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, + ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swiftui-navigation" */, + ABD970B527A2A6BD001693B0 /* XCRemoteSwiftPackageReference "R.swift.Library" */, + AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */, ); productRefGroup = ABC3C7552593696C00E0C11B /* Products */; projectDirPath = ""; @@ -780,19 +1246,14 @@ buildActionMask = 2147483647; files = ( ABC3C7852593699B00E0C11B /* Assets.xcassets in Resources */, - AB47FD9F25BC81A40007765D /* Normal@3x.png in Resources */, - AB47FDAF25BC85060007765D /* Normal-ipad.png in Resources */, + AB7BF2A627A6175E001865A3 /* AppIcon_Ukiyoe@2x.png in Resources */, + AB7BF2A727A6175E001865A3 /* AppIcon_Ukiyoe@3x.png in Resources */, AB7E6B3025D24FE00035CC68 /* InfoPlist.strings in Resources */, - AB47FDA225BC81A40007765D /* Weird@2x.png in Resources */, ABEE0AFA2595C6F800C997AE /* Localizable.strings in Resources */, - AB47FDA025BC81A40007765D /* Normal@2x.png in Resources */, - AB47FDB425BC859E0007765D /* Weird-ipad@2x.png in Resources */, - AB47FDB625BC859E0007765D /* Weird-ipad.png in Resources */, - AB47FDB525BC859E0007765D /* Weird-ipad-pro@2x.png in Resources */, + AB86AC072782F76000E61E6A /* AppIcon_Default@2x.png in Resources */, ABD5FDD4263D05110021A4C6 /* .swiftlint.yml in Resources */, - AB47FDAE25BC85060007765D /* Normal-ipad-pro@2x.png in Resources */, - AB47FDA125BC81A40007765D /* Weird@3x.png in Resources */, - AB47FDAD25BC85060007765D /* Normal-ipad@2x.png in Resources */, + AB86AC032782F76000E61E6A /* AppIcon_Default@3x.png in Resources */, + AB26F59B27AD125A00AB3468 /* Constant.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -814,6 +1275,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + AB2E936227A24E0A00EA99F1 /* R.Swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$TEMP_DIR/rswift-lastrun", + ); + name = R.Swift; + outputFileListPaths = ( + ); + outputPaths = ( + $SRCROOT/EhPanda/App/R.generated.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 rswift >/dev/null; then\n rswift generate --generators string \"$SRCROOT/EhPanda/App/R.generated.swift\"\nelse\n echo \"warning: R.Swift not installed, download from https://github.com/mac-cain13/R.swift\"\nfi\n"; + }; AB69FE41263C328400716FBD /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -848,85 +1329,170 @@ buildActionMask = 2147483647; files = ( ABA732D925A8018A00B3D9AB /* Extensions.swift in Sources */, + AB0929D02781E1CC00F107CA /* UIApplicationClient.swift in Sources */, ABF45AF325F3313D00ECB568 /* AccountSettingView.swift in Sources */, - ABF45ABD25F3312F00ECB568 /* Store.swift in Sources */, - ABF45AED25F3313D00ECB568 /* DetailView.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 */, - ABF45AE025F3313D00ECB568 /* HomeView.swift in Sources */, + AB7BF2FD27ABCAD4001865A3 /* MigrationStore.swift in Sources */, + AB7BF2D827AA3F61001865A3 /* UserDefaultsUtil.swift in Sources */, + AB0929CA278196ED00F107CA /* CookiesClient.swift in Sources */, + AB7BF2FB27ABCA3A001865A3 /* MigrationView.swift in Sources */, + AB7BF31C27ABE028001865A3 /* NSManagedObjectModel+Compatible.swift in Sources */, + AB7BF31B27ABE028001865A3 /* NSManagedObjectModel+Resource.swift in Sources */, ABBC332826BE31AE0084A331 /* EhSettingView.swift in Sources */, + AB7BF2C827A968F7001865A3 /* GalleryComment.swift in Sources */, + AB706F97278A77E20025A48A /* HistoryStore.swift in Sources */, + AB0929CC2781A0B000F107CA /* HapticClient.swift in Sources */, ABD4032626B78E5A00001B8C /* GalleryThumbnailCell.swift in Sources */, AB358319269D9996009466A5 /* DomainResolver.swift in Sources */, AB63EADD2699AC9100090535 /* AppEnvMO+CoreDataClass.swift in Sources */, + AB7BF2C027A9669A001865A3 /* TagCategory.swift in Sources */, ABCA93BE26918DE100A98BC6 /* Persistence.swift in Sources */, + AB86ABF92782EC0D00E61E6A /* EhPandaView.swift in Sources */, + AB7BF2BA27A96562001865A3 /* Gallery.swift in Sources */, + AB0929BE2780032400F107CA /* EhSettingStore.swift in Sources */, + AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */, + AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */, ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */, - AB60D0EB274CFB6D00F899AB /* SuggestionProvider.swift in Sources */, - ABF45AB925F3312F00ECB568 /* AppState.swift in Sources */, + AB706F99278A820C0025A48A /* FiltersStore.swift in Sources */, + AB3072D4276E19AA00EFF242 /* FrontpageView.swift in Sources */, + AB3072D2276D734800EFF242 /* SubSection.swift in Sources */, + ABBB26682797BFAA007B6149 /* ActivityView.swift in Sources */, + ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */, + ABBB264227942B74007B6149 /* URLClient.swift in Sources */, ABF45AF625F3313D00ECB568 /* AppearanceSettingView.swift in Sources */, + AB7BF2CE27AA3E58001865A3 /* AppUtil.swift in Sources */, + AB86AC1A2785C2B300E61E6A /* HomeStore.swift in Sources */, + AB7BF2D427AA3F12001865A3 /* CookiesUtil.swift in Sources */, + AB7BF30A27ABDFF1001865A3 /* CoreDataMigrationStep.swift in Sources */, AB69CB8226B3DAF400699359 /* ControlPanel.swift in Sources */, + AB7BF2D227AA3EDC001865A3 /* HapticUtil.swift in Sources */, + ABD49D5A277C5356003D1A07 /* FavoritesStore.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 */, + ABD49D6A277EEF73003D1A07 /* SettingStore.swift in Sources */, AB8C821926BF801700E8C5E6 /* EhSetting.swift in Sources */, - ABF45AF125F3313D00ECB568 /* AssociatedView.swift in Sources */, + AB86AC1327856F2700E61E6A /* AppLockStore.swift in Sources */, + AB58A5AC2776B2BC00C0D285 /* AppDelegateStore.swift in Sources */, + ABBB263E2793C648007B6149 /* PreviewsStore.swift in Sources */, ABBC332A26BE7C940084A331 /* SettingTextField.swift in Sources */, AB358317269D826B009466A5 /* DFStreamHandler.swift in Sources */, - ABF45ADF25F3313D00ECB568 /* FilterView.swift in Sources */, - ABE2AE752699F238001D47AA /* AppEnvStorage.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 */, + AB7BF30727ABDFF1001865A3 /* CoreDataMigrator.swift in Sources */, ABC3C78F2593699B00E0C11B /* ViewModifiers.swift in Sources */, - ABF45AF825F3313D00ECB568 /* EhPandaView.swift in Sources */, - ABCA93C42692A0BF00A98BC6 /* PersistenceAccessor.swift in Sources */, - AB73CEAF26AAC13F00EF6337 /* CodableExtension.swift in Sources */, - ABC3C7862593699B00E0C11B /* Utilities.swift in Sources */, + AB86ABF52782DAB300E61E6A /* LogsStore.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 */, + AB706F862789AD490025A48A /* ToplistsStore.swift in Sources */, + AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */, ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */, - ABD4032826B7967F00001B8C /* Category.swift in Sources */, + ABD970B427A2A39E001693B0 /* R.generated.swift in Sources */, + ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */, ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */, - ABF45AE125F3313D00ECB568 /* Home.swift in Sources */, + ABBB266627977C2A007B6149 /* ArchivesStore.swift in Sources */, + ABBB2640279417EC007B6149 /* CommentsStore.swift in Sources */, + AB0929C027805A8200F107CA /* LoginStore.swift in Sources */, + ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */, + AB706F92278A6E8C0025A48A /* WatchedStore.swift in Sources */, + AB706F80278981370025A48A /* AlertKit_Extension.swift in Sources */, + ABBB2633278E6F3B007B6149 /* SearchStore.swift in Sources */, + AB58A5B22776B99000C0D285 /* AppStore.swift in Sources */, + AB24C566276758E30085C33A /* GalleryCardCell.swift in Sources */, + ABBB2679279D454C007B6149 /* GalleryInfosStore.swift in Sources */, + AB7BF2B727A9652F001865A3 /* Greeting.swift in Sources */, + ABC8355F27B118370091DCDB /* DetailSearchStore.swift in Sources */, AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */, ABAFFE4026A86E3000EE8661 /* MeasureTool.swift in Sources */, + AB26F59427ACC6CD00AB3468 /* TagTranslator.swift in Sources */, + AB0929CE2781AADA00F107CA /* DatabaseClient.swift in Sources */, AB6DE897268822390087C579 /* LogsView.swift in Sources */, - AB7B29F226AC471E00EE1F14 /* MigrationPolicy.swift in Sources */, + AB7BF31D27ABE028001865A3 /* FileManager+ApplicationSupport.swift in Sources */, + AB706F7B278937500025A48A /* FrontpageStore.swift in Sources */, + AB86ABF72782DDE600E61E6A /* FileClient.swift in Sources */, + AB7B29F226AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift in Sources */, + AB706F7927890A6C0025A48A /* AppRouteStore.swift in Sources */, + AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */, + AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */, ABE9401526FF158D0085E158 /* QuickSearchView.swift in Sources */, - ABF45AF025F3313D00ECB568 /* CommentView.swift in Sources */, - ABF45AE325F3313D00ECB568 /* SlideMenu.swift in Sources */, - ABF45AE225F3313D00ECB568 /* AuthView.swift in Sources */, - ABF45ABC25F3312F00ECB568 /* AppAction.swift in Sources */, + AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */, + AB706F8A278A4CC50025A48A /* PopularStore.swift in Sources */, + ABD49D64277C7AD5003D1A07 /* TabBarStore.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 /* GeneralSettingStore.swift in Sources */, + AB706FA3278BCF2F0025A48A /* DetailStore.swift in Sources */, ABBCCC9026C95F6E007D8A36 /* GalleryInfosView.swift in Sources */, + AB7BF2A927A63C89001865A3 /* Language.swift in Sources */, + AB86AC0A2782FAFA00E61E6A /* AppearanceSettingStore.swift in Sources */, + AB7BF2C427A9683F001865A3 /* GalleryArchive.swift in Sources */, ABF45AF525F3313D00ECB568 /* ReadingSettingView.swift in Sources */, AB38A0CB25CA993D00764D64 /* ColorCodable.swift in Sources */, + ABBB2675279B933D007B6149 /* ReadingStore.swift in Sources */, ABF45AF425F3313D00ECB568 /* WebView.swift in Sources */, AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */, + AB706F82278986120025A48A /* ToolbarItems.swift in Sources */, + ABBB266E27998479007B6149 /* QuickSearchStore.swift in Sources */, AB0ABCB526C5406400AD970F /* LoginView.swift in Sources */, + AB24C55C2767565A0085C33A /* HomeView.swift in Sources */, ABF45AF225F3313D00ECB568 /* GeneralSettingView.swift in Sources */, AB358311269D7B63009466A5 /* DFURLProtocol.swift in Sources */, - ABF45ABA25F3312F00ECB568 /* AppCommand.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 */, - ABF45AEE25F3313D00ECB568 /* ArchiveView.swift in Sources */, - ABF45AEC25F3313D00ECB568 /* ReadingView.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 */, - ABC3C7962593699B00E0C11B /* Models.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 /* SearchRootStore.swift in Sources */, ABF313A525B1AB6600D47A2F /* Misc.swift in Sources */, ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */, + AB7BF31E27ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift in Sources */, ABC1FAB82642C37D00A9F352 /* NewDawnView.swift in Sources */, ABF45AE825F3313D00ECB568 /* LinkedText.swift in Sources */, + AB0929B6277F043D00F107CA /* AccountSettingStore.swift in Sources */, + ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */, + ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */, AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */, - ABF971FF26DD394200118887 /* ImageSaver.swift in Sources */, - ABF45AE525F3313D00ECB568 /* Comment.swift in Sources */, + AB26F59627ACCA1800AB3468 /* AppEnv.swift in Sources */, + ABF45AE525F3313D00ECB568 /* PostCommentView.swift in Sources */, AB358313269D7E89009466A5 /* DFRequest.swift in Sources */, - ABC1FAB6264152C800A9F352 /* StoreAccessor.swift in Sources */, + AB706F90278A5F680025A48A /* AppDelegateClient.swift in Sources */, + ABBB266A2797C61F007B6149 /* TorrentsStore.swift in Sources */, ABF75F3F25A19CD200544D29 /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1319,6 +1885,22 @@ kind = branch; }; }; + AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Co2333/Colorful.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jathu/UIImageColors.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; AB19D617266E5C6700BA752A /* XCRemoteSwiftPackageReference "TTProgressHUD" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/tatsuz0u/TTProgressHUD.git"; @@ -1335,12 +1917,12 @@ kind = branch; }; }; - AB60D0CD274C7AA000F899AB /* XCRemoteSwiftPackageReference "BetterCodable" */ = { + AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/marksands/BetterCodable.git"; + repositoryURL = "https://github.com/markrenaud/FilePicker.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.4.0; + minimumVersion = 1.0.0; }; }; AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = { @@ -1359,6 +1941,14 @@ minimumVersion = 2.0.0; }; }; + AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.32.0; + }; + }; ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ddddxxx/SwiftyOpenCC.git"; @@ -1367,11 +1957,27 @@ minimumVersion = "2.0.0-beta"; }; }; + ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swiftui-navigation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swiftui-navigation.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { - branch = master; + kind = upToNextMajorVersion; + minimumVersion = 7.1.2; + }; + }; + ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols.git"; + requirement = { + branch = stable; kind = branch; }; }; @@ -1383,6 +1989,14 @@ minimumVersion = 5.0.0; }; }; + ABD970B527A2A6BD001693B0 /* XCRemoteSwiftPackageReference "R.swift.Library" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/mac-cain13/R.swift.Library.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.4.0; + }; + }; ABE9402B26FF89220085E158 /* XCRemoteSwiftPackageReference "AlertKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/tatsuz0u/AlertKit.git"; @@ -1399,6 +2013,16 @@ package = AB0F68AD26A6D92F00AC3A54 /* XCRemoteSwiftPackageReference "DeprecatedAPI" */; productName = DeprecatedAPI; }; + AB17573C27675B1E00FD64E2 /* Colorful */ = { + isa = XCSwiftPackageProductDependency; + package = AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */; + productName = Colorful; + }; + AB17573F27678B3400FD64E2 /* UIImageColors */ = { + isa = XCSwiftPackageProductDependency; + package = AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */; + productName = UIImageColors; + }; AB19D618266E5C6700BA752A /* TTProgressHUD */ = { isa = XCSwiftPackageProductDependency; package = AB19D617266E5C6700BA752A /* XCRemoteSwiftPackageReference "TTProgressHUD" */; @@ -1409,10 +2033,10 @@ package = AB21CC9E274B4F0C00C115B1 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */; productName = SwiftyBeaver; }; - AB60D0CE274C7AA000F899AB /* BetterCodable */ = { + AB26F59827ACDB4200AB3468 /* FilePicker */ = { isa = XCSwiftPackageProductDependency; - package = AB60D0CD274C7AA000F899AB /* XCRemoteSwiftPackageReference "BetterCodable" */; - productName = BetterCodable; + package = AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */; + productName = FilePicker; }; AB60D0E8274C7ECE00F899AB /* WaterfallGrid */ = { isa = XCSwiftPackageProductDependency; @@ -1424,21 +2048,41 @@ package = AB65059E26B0027800F91E9D /* XCRemoteSwiftPackageReference "SwiftUIPager" */; productName = SwiftUIPager; }; + AB86AC0F27831AD100E61E6A /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + package = AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */; + productName = ComposableArchitecture; + }; ABAC82FD26BC4A96009F5026 /* OpenCC */ = { isa = XCSwiftPackageProductDependency; package = ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */; productName = OpenCC; }; + ABBB2635278FB888007B6149 /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; + productName = SwiftUINavigation; + }; ABC4A0782751B40E00968A4F /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */ = { + isa = XCSwiftPackageProductDependency; + package = ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; ABD7005826B1C31500DC59C9 /* Kanna */ = { isa = XCSwiftPackageProductDependency; package = ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */; productName = Kanna; }; + ABD970B627A2A6BD001693B0 /* Rswift */ = { + isa = XCSwiftPackageProductDependency; + package = ABD970B527A2A6BD001693B0 /* XCRemoteSwiftPackageReference "R.swift.Library" */; + productName = Rswift; + }; ABE9402C26FF89220085E158 /* AlertKit */ = { isa = XCSwiftPackageProductDependency; package = ABE9402B26FF89220085E158 /* XCRemoteSwiftPackageReference "AlertKit" */; @@ -1450,13 +2094,14 @@ ABC681F126898D46007BBD69 /* Model.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + AB706F93278A6F2B0025A48A /* Model 6.xcdatamodel */, ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */, ABE9401626FF2E610085E158 /* Model 4.xcdatamodel */, AB543FF126DB7FD9009344C0 /* Model 3.xcdatamodel */, AB48BCF626D2539B0021A06C /* Model 2.xcdatamodel */, ABC681F226898D46007BBD69 /* Model.xcdatamodel */, ); - currentVersion = ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */; + currentVersion = AB706F93278A6F2B0025A48A /* Model 6.xcdatamodel */; path = Model.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bebbb4d0..41647680 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,17 +6,26 @@ "repositoryURL": "https://github.com/tatsuz0u/AlertKit.git", "state": { "branch": "custom", - "revision": "7be1b8fe55399ddd60826b0206de1cf0bc767f6f", + "revision": "39b01c53ffadf3dab9871dd4c960cd81af5246b6", "version": null } }, { - "package": "BetterCodable", - "repositoryURL": "https://github.com/marksands/BetterCodable.git", + "package": "Colorful", + "repositoryURL": "https://github.com/Co2333/Colorful.git", "state": { "branch": null, - "revision": "61153170668db7a46a20a87e35e70f80b24d4eb5", - "version": "0.4.0" + "revision": "eb5a350aec759bd413615273cb6d64553aead4d5", + "version": "1.0.1" + } + }, + { + "package": "combine-schedulers", + "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", + "state": { + "branch": null, + "revision": "4cf088c29a20f52be0f2ca54992b492c54e0076b", + "version": "0.5.3" } }, { @@ -28,6 +37,15 @@ "version": null } }, + { + "package": "FilePicker", + "repositoryURL": "https://github.com/markrenaud/FilePicker.git", + "state": { + "branch": null, + "revision": "720f8cb5ca0c0efc982ed381afc84ba3e8b3214e", + "version": "1.0.1" + } + }, { "package": "Kanna", "repositoryURL": "https://github.com/tid-kijyun/Kanna.git", @@ -41,11 +59,83 @@ "package": "Kingfisher", "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { - "branch": "master", - "revision": "c874e5ef178a65b6a2b6a04d5981652d4fa95e22", + "branch": null, + "revision": "0c02c46cfdc0656ce74fd0963a75e5000a0b7f23", + "version": "7.1.2" + } + }, + { + "package": "R.swift.Library", + "repositoryURL": "https://github.com/mac-cain13/R.swift.Library.git", + "state": { + "branch": null, + "revision": "8998cfe77f4fce79ee6dfab0c88a7d551659d8fb", + "version": "5.4.0" + } + }, + { + "package": "SFSafeSymbols", + "repositoryURL": "https://github.com/SFSafeSymbols/SFSafeSymbols.git", + "state": { + "branch": "stable", + "revision": "846e558cda47e1a2d2cea988a9c18378f830bddf", "version": null } }, + { + "package": "swift-case-paths", + "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", + "state": { + "branch": null, + "revision": "241301b67d8551c26d8f09bd2c0e52cc49f18007", + "version": "0.8.0" + } + }, + { + "package": "swift-collections", + "repositoryURL": "https://github.com/apple/swift-collections", + "state": { + "branch": null, + "revision": "48254824bb4248676bf7ce56014ff57b142b77eb", + "version": "1.0.2" + } + }, + { + "package": "swift-composable-architecture", + "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture.git", + "state": { + "branch": null, + "revision": "ba9c626ab1b2b6af8cf684eebb2ab472fa5b6753", + "version": "0.33.1" + } + }, + { + "package": "swift-custom-dump", + "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", + "state": { + "branch": null, + "revision": "51698ece74ecf31959d3fa81733f0a5363ef1b4e", + "version": "0.3.0" + } + }, + { + "package": "swift-identified-collections", + "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections", + "state": { + "branch": null, + "revision": "680bf440178a78a627b1c2c64c0855f6523ad5b9", + "version": "0.3.2" + } + }, + { + "package": "swiftui-navigation", + "repositoryURL": "https://github.com/pointfreeco/swiftui-navigation.git", + "state": { + "branch": null, + "revision": "2694c03284a368168b3e0b8d7ab52626802d2246", + "version": "0.1.0" + } + }, { "package": "SwiftUIPager", "repositoryURL": "https://github.com/fermoya/SwiftUIPager.git", @@ -82,6 +172,15 @@ "version": null } }, + { + "package": "UIImageColors", + "repositoryURL": "https://github.com/jathu/UIImageColors.git", + "state": { + "branch": null, + "revision": "e49e6c32ea556e9fa0109dc79686bea4a10d41a2", + "version": "2.2.0" + } + }, { "package": "WaterfallGrid", "repositoryURL": "https://github.com/paololeonardi/WaterfallGrid.git", @@ -90,6 +189,15 @@ "revision": "944aa82832ed5a9eaaf50862cdd53e3c10ab55eb", "version": "1.0.1" } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "50a70a9d3583fe228ce672e8923010c8df2deddd", + "version": "0.2.1" + } } ] }, diff --git a/EhPanda/App/AltIcons/Normal-ipad-pro@2x.png b/EhPanda/App/AltIcons/Normal-ipad-pro@2x.png deleted file mode 100644 index 67f15a64..00000000 Binary files a/EhPanda/App/AltIcons/Normal-ipad-pro@2x.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Normal-ipad.png b/EhPanda/App/AltIcons/Normal-ipad.png deleted file mode 100644 index 1a9e4245..00000000 Binary files a/EhPanda/App/AltIcons/Normal-ipad.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Normal-ipad@2x.png b/EhPanda/App/AltIcons/Normal-ipad@2x.png deleted file mode 100644 index 5ff67c0b..00000000 Binary files a/EhPanda/App/AltIcons/Normal-ipad@2x.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Normal@2x.png b/EhPanda/App/AltIcons/Normal@2x.png deleted file mode 100644 index f938c1cc..00000000 Binary files a/EhPanda/App/AltIcons/Normal@2x.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Normal@3x.png b/EhPanda/App/AltIcons/Normal@3x.png deleted file mode 100644 index 87e438e7..00000000 Binary files a/EhPanda/App/AltIcons/Normal@3x.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Weird-ipad-pro@2x.png b/EhPanda/App/AltIcons/Weird-ipad-pro@2x.png deleted file mode 100644 index 9f099f78..00000000 Binary files a/EhPanda/App/AltIcons/Weird-ipad-pro@2x.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Weird-ipad.png b/EhPanda/App/AltIcons/Weird-ipad.png deleted file mode 100644 index e4052eb5..00000000 Binary files a/EhPanda/App/AltIcons/Weird-ipad.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Weird-ipad@2x.png b/EhPanda/App/AltIcons/Weird-ipad@2x.png deleted file mode 100644 index 2f213aed..00000000 Binary files a/EhPanda/App/AltIcons/Weird-ipad@2x.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Weird@2x.png b/EhPanda/App/AltIcons/Weird@2x.png deleted file mode 100644 index f7935809..00000000 Binary files a/EhPanda/App/AltIcons/Weird@2x.png and /dev/null differ diff --git a/EhPanda/App/AltIcons/Weird@3x.png b/EhPanda/App/AltIcons/Weird@3x.png deleted file mode 100644 index 83a337be..00000000 Binary files a/EhPanda/App/AltIcons/Weird@3x.png and /dev/null differ diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/AppIcon_Default.png b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/AppIcon_Default.png deleted file mode 100644 index 277fdda0..00000000 Binary files a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/AppIcon_Default.png and /dev/null differ diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/Contents.json b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/Contents.json deleted file mode 100644 index 13b80230..00000000 --- a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon_Default.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "AppIcon_Default@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "AppIcon_Default@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal.png b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal.png deleted file mode 100644 index 821b11b7..00000000 Binary files a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal.png and /dev/null differ diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal@2x.png b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal@2x.png deleted file mode 100644 index f938c1cc..00000000 Binary files a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal@2x.png and /dev/null differ diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal@3x.png b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal@3x.png deleted file mode 100644 index 87e438e7..00000000 Binary files a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/AppIcon_Normal@3x.png and /dev/null differ diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/Contents.json b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/Contents.json deleted file mode 100644 index 4ed0bc5d..00000000 --- a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Normal.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon_Normal.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "AppIcon_Normal@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "AppIcon_Normal@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird.png b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird.png deleted file mode 100644 index 2ca51a41..00000000 Binary files a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird.png and /dev/null differ diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird@2x.png b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird@2x.png deleted file mode 100644 index f7935809..00000000 Binary files a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird@2x.png and /dev/null differ diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird@3x.png b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird@3x.png deleted file mode 100644 index 83a337be..00000000 Binary files a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/AppIcon_Weird@3x.png and /dev/null differ diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/Contents.json b/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/Contents.json deleted file mode 100644 index 242b0cbf..00000000 --- a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Weird.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon_Weird.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "AppIcon_Weird@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "AppIcon_Weird@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EhPanda/App/Assets.xcassets/Icons/Contents.json b/EhPanda/App/Assets.xcassets/Icons/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/EhPanda/App/Assets.xcassets/Icons/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EhPanda/App/Constant.strings b/EhPanda/App/Constant.strings new file mode 100644 index 00000000..ab3d162b --- /dev/null +++ b/EhPanda/App/Constant.strings @@ -0,0 +1,88 @@ +/* + Constant.strings + EhPanda + + Created by 荒木辰造 on R 4/02/04. + +*/ + +// MARK: Website response +"website.response.hathClientNotFound" = "You must have a H@H client assigned to your account to use this feature."; +"website.response.hathClientNotOnline" = "Your H@H client appears to be offline. Turn it on, then try again."; +"website.response.invalidResolution" = "The requested gallery cannot be downloaded with the selected resolution."; +"website.response.galleryUnavailable" = "This gallery has been removed or is unavailable."; + +// MARK: EhPanda +"ehpanda.copyright" = "Copyright © 2022 荒木辰造"; + +// Contacts +"ehpanda.contacts.link.website" = "https://ehpanda.app"; +"ehpanda.contacts.link.gitHub" = "https://github.com/tatsuz0u/EhPanda"; +"ehpanda.contacts.link.discord" = "https://discord.gg/BSBE9FCBTq"; +"ehpanda.contacts.link.telegram" = "https://t.me/ehpanda"; +"ehpanda.contacts.link.altStore" = "altstore://source?url=https://github.com/tatsuz0u/EhPanda/raw/main/AltStore.json"; +"ehpanda.contacts.text.gitHub" = "GitHub"; +"ehpanda.contacts.text.discord" = "Discord"; +"ehpanda.contacts.text.telegram" = "Telegram"; + +// Special thanks +"ehpanda.special.thanks.link.taylorlannister" = "https://github.com/taylorlannister"; +"ehpanda.special.thanks.link.Luminescent_yq" = ""; +"ehpanda.special.thanks.link.caxerx" = "https://github.com/caxerx"; +"ehpanda.special.thanks.link.honjow" = "https://github.com/honjow"; +"ehpanda.special.thanks.text.taylorlannister" = "taylorlannister"; +"ehpanda.special.thanks.text.Luminescent_yq" = "Luminescent_yq"; +"ehpanda.special.thanks.text.caxerx" = "caxerx"; +"ehpanda.special.thanks.text.honjow" = "honjow"; + +// Code level contributors +"ehpanda.code.level.contributors.link.tatsuz0u" = "https://github.com/tatsuz0u"; +"ehpanda.code.level.contributors.link.leng-yue" = "https://github.com/leng-yue"; +"ehpanda.code.level.contributors.text.tatsuz0u" = "Tatsuzou Araki"; +"ehpanda.code.level.contributors.text.leng-yue" = "LengYue"; + +// Translation contributors +"ehpanda.translation.contributors.link.tatsuz0u" = "https://github.com/tatsuz0u"; +"ehpanda.translation.contributors.link.PaulHaeussler" = "https://github.com/PaulHaeussler"; +"ehpanda.translation.contributors.link.caxerx" = "https://github.com/caxerx"; +"ehpanda.translation.contributors.link.nyaanim" = "https://github.com/nyaanim"; +"ehpanda.translation.contributors.text.tatsuz0u" = "Tatsuzou Araki"; +"ehpanda.translation.contributors.text.PaulHaeussler" = "PaulHaeussler"; +"ehpanda.translation.contributors.text.caxerx" = "caxerx"; +"ehpanda.translation.contributors.text.nyaanim" = "nyaanim"; + +// Acknowledgements links +"ehpanda.acknowledgements.link.kanna" = "https://github.com/tid-kijyun/Kanna"; +"ehpanda.acknowledgements.link.rswift" = "https://github.com/mac-cain13/R.swift"; +"ehpanda.acknowledgements.link.alertKit" = "https://github.com/rebeloper/AlertKit"; +"ehpanda.acknowledgements.link.colorful" = "https://github.com/Co2333/Colorful"; +"ehpanda.acknowledgements.link.filePicker" = "https://github.com/markrenaud/FilePicker"; +"ehpanda.acknowledgements.link.kingfisher" = "https://github.com/onevcat/Kingfisher"; +"ehpanda.acknowledgements.link.swiftUIPager" = "https://github.com/fermoya/SwiftUIPager"; +"ehpanda.acknowledgements.link.swiftyBeaver" = "https://github.com/SwiftyBeaver/SwiftyBeaver"; +"ehpanda.acknowledgements.link.waterfallGrid" = "https://github.com/paololeonardi/WaterfallGrid"; +"ehpanda.acknowledgements.link.swiftyOpenCC" = "https://github.com/ddddxxx/SwiftyOpenCC"; +"ehpanda.acknowledgements.link.UIImageColors" = "https://github.com/jathu/UIImageColors"; +"ehpanda.acknowledgements.link.SFSafeSymbols" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; +"ehpanda.acknowledgements.link.TTProgressHUD" = "https://github.com/honkmaster/TTProgressHUD"; +"ehpanda.acknowledgements.link.swiftUINavigation" = "https://github.com/pointfreeco/swiftui-navigation"; +"ehpanda.acknowledgements.link.ehTagTranslationDatabase" = "https://github.com/EhTagTranslation/Database"; +"ehpanda.acknowledgements.link.TCA" = "https://github.com/pointfreeco/swift-composable-architecture"; + +// Acknowledgements texts +"ehpanda.acknowledgements.text.kanna" = "Kanna"; +"ehpanda.acknowledgements.text.rswift" = "R.swift"; +"ehpanda.acknowledgements.text.alertKit" = "AlertKit"; +"ehpanda.acknowledgements.text.colorful" = "Colorful"; +"ehpanda.acknowledgements.text.filePicker" = "FilePicker"; +"ehpanda.acknowledgements.text.kingfisher" = "Kingfisher"; +"ehpanda.acknowledgements.text.swiftUIPager" = "SwiftUIPager"; +"ehpanda.acknowledgements.text.swiftyBeaver" = "SwiftyBeaver"; +"ehpanda.acknowledgements.text.waterfallGrid" = "WaterfallGrid"; +"ehpanda.acknowledgements.text.swiftyOpenCC" = "SwiftyOpenCC"; +"ehpanda.acknowledgements.text.UIImageColors" = "UIImageColors"; +"ehpanda.acknowledgements.text.SFSafeSymbols" = "SFSafeSymbols"; +"ehpanda.acknowledgements.text.TTProgressHUD" = "TTProgressHUD"; +"ehpanda.acknowledgements.text.swiftUINavigation" = "SwiftUI Navigation"; +"ehpanda.acknowledgements.text.ehTagTranslationDatabase" = "EhTagTranslation/Database"; +"ehpanda.acknowledgements.text.TCA" = "The Composable Architecture"; diff --git a/EhPanda/App/Defaults.swift b/EhPanda/App/Defaults.swift deleted file mode 100644 index 3993828b..00000000 --- a/EhPanda/App/Defaults.swift +++ /dev/null @@ -1,363 +0,0 @@ -// -// Defaults.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/11/22. -// - -import UIKit -import Foundation - -struct Defaults { - struct FrameSize { - static var slideMenuWidth: CGFloat { - if DeviceUtil.isPadWidth { - return max(DeviceUtil.windowW - 500, 300) - } else { - return max(DeviceUtil.windowW - 90, 250) - } - } - static let archiveGridWidth: CGFloat = - DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 - } - struct ImageSize { - static let rowAspect: CGFloat = 8/11 - static let avatarAspect: CGFloat = 1/1 - static let headerAspect: CGFloat = 8/11 - static let previewAspect: CGFloat = 8/11 - static let contentAspect: CGFloat = 7/10 - static let webtoonMinAspect: CGFloat = 1/4 - static let webtoonIdealAspect: CGFloat = 2/3 - - static let rowW: CGFloat = rowH * rowAspect - static let rowH: CGFloat = 110 - static let avatarW: CGFloat = 100 - static let avatarH: CGFloat = 100 - 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 - } - struct Cookie { - static let null = "null" - static let expired = "expired" - static let mystery = "mystery" - static let selectedProfile = "sp" - static let skipServer = "skipserver" - - static let igneous = "igneous" - static let ipbMemberId = "ipb_member_id" - static let ipbPassHash = "ipb_pass_hash" - } - struct DateFormat { - static let greeting = "dd MMMM yyyy" - static let publish = "yyyy-MM-dd HH:mm" - static let torrent = "yyyy-MM-dd HH:mm" - static let comment = "dd MMMM yyyy, HH:mm" - static let github = "yyyy-MM-dd'T'HH:mm:ss'Z'" - } - struct FilePath { - static let logs = "logs" - static let ehpandaLog = "EhPanda.log" - } - struct PreviewIdentifier { - static let width = "?ehpandaWidth=" - static let height = "&ehpandaHeight=" - static let offset = "&ehpandaOffset=" - } - struct Response { - static let hathClientNotFound = "You must have a H@H client assigned to your account to use this feature." - static let hathClientNotOnline = "Your H@H client appears to be offline. Turn it on, then try again." - static let invalidResolution = "The requested gallery cannot be downloaded with the selected resolution." - } - struct ParsingMark { - static let hexStart = "hexStart<" - static let hexEnd = ">hexEnd" - } - struct URL { - // Domains - static var host: String { - AppUtil.galleryHost == .exhentai ? exhentai : ehentai - } - static let ehentai = "https://e-hentai.org/" - static let exhentai = "https://exhentai.org/" - static let forum = "https://forums.e-hentai.org/index.php" - static let login = merge(urls: [forum, "act=Login", "CODE=01"]) - static let webLogin = merge(urls: [forum, "act=Login"]) - static let magnet = "magnet:?xt=urn:btih:" - - // Functional Pages - static let tag = "tag/" - static let popular = "popular" - static let watched = "watched" - static let mytags = "mytags" - static let api = "api.php" - static let news = "news.php" - static let uconfig = "uconfig.php" - static let favorites = "favorites.php" - static let toplist = "toplist.php" - static let gallerypopups = "gallerypopups.php" - static let gallerytorrents = "gallerytorrents.php" - - static let token = "t=" - static let gid = "gid=" - static let page1 = "p=" - static let page2 = "page=" - static let from = "from=" - static let favcat = "favcat=" - static let topcat = "tl=" - static let showuser = "showuser=" - static let fSearch = "f_search=" - - static let showComments = "hc=1" - static let loginAct = "act=Login" - static let addfavAct = "act=addfav" - static let ignoreOffensive = "nw=always" - static let rowsLimit = "inline_set=tr_" - static let listCompact = "inline_set=dm_l" - static let previewNormal = "inline_set=ts_m" - static let previewLarge = "inline_set=ts_l" - static let sortOrderByUpdateTime = "inline_set=fs_p" - static let sortOrderByFavoritedTime = "inline_set=fs_f" - - // Filter - static let fCats = "f_cats=" - static let advSearch = "advsearch=1" - static let fSnameOn = "f_sname=on" - static let fStagsOn = "f_stags=on" - static let fSdescOn = "f_sdesc=on" - static let fStorrOn = "f_storr=on" - static let fStoOn = "f_sto=on" - static let fSdt1On = "f_sdt1=on" - static let fSdt2On = "f_sdt2=on" - static let fShOn = "f_sh=on" - static let fSrOn = "f_sr=on" - static let fSrdd = "f_srdd=" - static let fSpOn = "f_sp=on" - static let fSpf = "f_spf=" - static let fSpt = "f_spt=" - static let fSflOn = "f_sfl=on" - static let fSfuOn = "f_sfu=on" - static let fSftOn = "f_sft=on" - - // GitHub - static let github = "https://github.com/" - static let githubAPI = "https://api.github.com/repos/" - static let pathToLatest = "/releases/latest" - static let pathToDownload = pathToLatest + "/download/" - static let ehTagTrasnlationRepo = "EhTagTranslation/Database" - static let ehTagTranslationJpnRepo = "tatsuz0u/EhTagTranslation_Database_JPN" - } -} - -// MARK: Request -extension Defaults.URL { - // Fetch - static func searchList(keyword: String, filter: Filter, pageNum: Int? = nil) -> String { - var params = [host, fSearch + keyword.urlEncoded()] - if let pageNum = pageNum { params.append(page2 + String(pageNum)) } - return merge(urls: params + applyFilters(filter: filter)) - } - static func moreSearchList(keyword: String, filter: Filter, pageNum: Int, lastID: String) -> String { - merge( - urls: [host, fSearch + keyword.urlEncoded(), page2 + String(pageNum), from + lastID] - + applyFilters(filter: filter) - ) - } - static func frontpageList(filter: Filter, pageNum: Int? = nil) -> String { - if let pageNum = pageNum { - return merge(urls: [host, page2 + String(pageNum)] + applyFilters(filter: filter)) - } else { - return merge(urls: [host] + applyFilters(filter: filter)) - } - } - static func moreFrontpageList(filter: Filter, pageNum: Int, lastID: String) -> String { - merge(urls: [host, page2 + String(pageNum), from + lastID] + applyFilters(filter: filter)) - } - static func popularList(filter: Filter) -> String { - merge(urls: [host + popular] + applyFilters(filter: filter)) - } - static func watchedList(filter: Filter, pageNum: Int? = nil) -> String { - if let pageNum = pageNum { - return merge(urls: [host + watched, page2 + String(pageNum)]) - } else { - return merge(urls: [host + watched] + applyFilters(filter: filter)) - } - } - static func moreWatchedList(filter: Filter, pageNum: Int, lastID: String) -> String { - merge(urls: [host + watched, page2 + String(pageNum), from + lastID] + applyFilters(filter: filter)) - } - static func favoritesList(favIndex: Int, pageNum: Int? = nil, sortOrder: FavoritesSortOrder? = nil) -> String { - var params = [host + favorites] - if favIndex == -1 { - if pageNum == nil { - guard let sortOrder = sortOrder else { - return merge(urls: params) - } - params.append(sortOrder == .favoritedTime ? sortOrderByFavoritedTime : sortOrderByUpdateTime) - return merge(urls: params) - } - } else { - params.append(favcat + "\(favIndex)") - } - if let pageNum = pageNum { - params.append(page2 + String(pageNum)) - } - if let sortOrder = sortOrder { - params.append(sortOrder == .favoritedTime ? sortOrderByFavoritedTime : sortOrderByUpdateTime) - } - return merge(urls: params) - } - static func moreFavoritesList(favIndex: Int, pageNum: Int, lastID: String) -> String { - if favIndex == -1 { - return merge(urls: [host + favorites, page2 + String(pageNum), from + lastID]) - } else { - return merge(urls: [host + favorites, favcat + "\(favIndex)", page2 + String(pageNum), from + lastID]) - } - } - static func toplistsList(catIndex: Int, pageNum: Int? = nil) -> String { - if let pageNum = pageNum { - return merge(urls: [ehentai + toplist, topcat + "\(catIndex)", page1 + String(pageNum)]) - } else { - return merge(urls: [ehentai + toplist, topcat + "\(catIndex)"]) - } - } - static func moreToplistsList(catIndex: Int, pageNum: Int) -> String { - merge(urls: [ehentai + toplist, topcat + "\(catIndex)", page1 + String(pageNum)]) - } - static func galleryDetail(url: String) -> String { - merge(urls: [url, showComments]) - } - static func galleryTorrents(gid: String, token: String) -> String { - merge(urls: [host + gallerytorrents, Defaults.URL.gid + gid, Defaults.URL.token + token]) - } - - // Account Associated Operations - static func addFavorite(gid: String, token: String) -> String { - merge(urls: [host + gallerypopups, Defaults.URL.gid + gid, Defaults.URL.token + token, addfavAct]) - } - static func userInfo(uid: String) -> String { - merge(urls: [forum, showuser + uid]) - } - static func greeting() -> String { - ehentai + news - } - - // Misc - static func detailPage(url: String, pageNum: Int) -> String { - merge(urls: [url, page1 + "\(pageNum)"]) - } - static func magnet(hash: String) -> String { - magnet + hash - } - static func ehAPI() -> String { - host + api - } - static func ehFavorites() -> String { - host + favorites - } - static func ehConfig() -> String { - host + uconfig - } - static func ehMyTags() -> String { - host + mytags - } - static func normalPreview( - plainURL: Substring, width: Substring, - height: Substring, offset: Substring - ) -> String { - plainURL - + Defaults.PreviewIdentifier.width + width - + Defaults.PreviewIdentifier.height + height - + Defaults.PreviewIdentifier.offset + offset - } -} - -// MARK: Filter -private extension Defaults.URL { - static func applyFilters(filter: Filter) -> [String] { - var filters = [String]() - - var category = 0 - category += filter.doujinshi ? Category.doujinshi.value : 0 - category += filter.manga ? Category.manga.value : 0 - category += filter.artistCG ? Category.artistCG.value : 0 - category += filter.gameCG ? Category.gameCG.value : 0 - category += filter.western ? Category.western.value : 0 - category += filter.nonH ? Category.nonH.value : 0 - category += filter.imageSet ? Category.imageSet.value : 0 - category += filter.cosplay ? Category.cosplay.value : 0 - category += filter.asianPorn ? Category.asianPorn.value : 0 - category += filter.misc ? Category.misc.value : 0 - - if ![0, 1023].contains(category) { - filters.append(fCats + "\(category)") - } - - if !filter.advanced { return filters } - filters.append(advSearch) - - if filter.galleryName { filters.append(fSnameOn) } - if filter.galleryTags { filters.append(fStagsOn) } - if filter.galleryDesc { filters.append(fSdescOn) } - if filter.torrentFilenames { filters.append(fStorrOn) } - if filter.onlyWithTorrents { filters.append(fStoOn) } - if filter.lowPowerTags { filters.append(fSdt1On) } - if filter.downvotedTags { filters.append(fSdt2On) } - if filter.expungedGalleries { filters.append(fShOn) } - - if filter.minRatingActivated, [2, 3, 4, 5].contains(filter.minRating) { - filters.append(fSrOn) - filters.append(fSrdd + "\(filter.minRating)") - } - - if filter.pageRangeActivated, let minPages = Int(filter.pageLowerBound), - let maxPages = Int(filter.pageUpperBound), - minPages > 0 && maxPages > 0 && minPages <= maxPages - { - filters.append(fSpOn) - filters.append(fSpf + "\(minPages)") - filters.append(fSpt + "\(maxPages)") - } - - if filter.disableLanguage { filters.append(fSflOn) } - if filter.disableUploader { filters.append(fSfuOn) } - if filter.disableTags { filters.append(fSftOn) } - - return filters - } -} - -// MARK: GitHub -extension Defaults.URL { - static func githubAPI(repoName: String) -> String { - githubAPI + repoName + pathToLatest - } - static func githubDownload(repoName: String, fileName: String) -> String { - github + repoName + pathToDownload + fileName - } -} - -// MARK: Tools -private extension Defaults.URL { - static func merge(urls: [String]) -> String { - guard !urls.isEmpty else { return "" } - guard urls.count > 1 else { return urls[0] } - let firstTwo = urls.prefix(2) - let remainder = urls.suffix(from: 2) - - var joinedArray = [String]() - joinedArray.append(firstTwo.joined(separator: "?")) - - if remainder.count > 0 { - joinedArray.append(remainder.joined(separator: "&")) - } - - if joinedArray.count > 1 { - return joinedArray.joined(separator: "&") - } else { - return joinedArray.joined() - } - } -} diff --git a/EhPanda/App/EhPandaApp.swift b/EhPanda/App/EhPandaApp.swift index 9175449d..e418f347 100644 --- a/EhPanda/App/EhPandaApp.swift +++ b/EhPanda/App/EhPandaApp.swift @@ -6,76 +6,48 @@ // import SwiftUI -import Kingfisher -import SwiftyBeaver +import ComposableArchitecture -@main -struct EhPandaApp: App { +@main struct EhPandaApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @StateObject private var store = Store() var body: some Scene { WindowGroup { - Home() - .accentColor(accentColor) - .environmentObject(store) - .onAppear(perform: onStartTasks) - .preferredColorScheme(preferredColorScheme) + WithViewStore( + appDelegate.store.scope(state: \.appDelegateState.migrationState.databaseState) + ) { viewStore in + ZStack { + if viewStore.state == .idle { + TabBarView(store: appDelegate.store).onAppear(perform: addTouchHandler).accentColor(.primary) + } + MigrationView( + store: appDelegate.store.scope( + state: \.appDelegateState.migrationState, + action: { AppAction.appDelegate(.migration($0)) } + ) + ) + .opacity(viewStore.state != .idle ? 1 : 0) + .animation(.linear(duration: 0.5), value: viewStore.state) + } + .navigationViewStyle(.stack) + } } } } -private extension EhPandaApp { - var setting: Setting { - store.appState.settings.setting - } - var accentColor: Color { - setting.accentColor - } - var preferredColorScheme: ColorScheme? { - setting.colorScheme - } -} - -// MARK: Tasks -private extension EhPandaApp { - func onStartTasks() { - AppUtil.dispatchMainSync { - configureWebImage() - configureDomainFronting() - } - addTouchHandler() - configureLogging() - fetchTagTranslator() - fetchIgneousIfNeeded() - configureIgnoreOffensive() - fetchAccountInfoIfNeeded() - } - - func fetchTagTranslator() { - store.dispatch(.fetchTagTranslator) - } - func fetchAccountInfoIfNeeded() { - guard AuthorizationUtil.didLogin else { return } - - CookiesUtil.removeYay() - store.dispatch(.fetchUserInfo) - store.dispatch(.verifyEhProfile) - store.dispatch(.fetchFavoriteNames) - } - func fetchIgneousIfNeeded() { - let url = Defaults.URL.exhentai.safeURL() - guard setting.bypassesSNIFiltering, - !CookiesUtil.get(for: url, key: Defaults.Cookie.ipbMemberId).rawValue.isEmpty, - !CookiesUtil.get(for: url, key: Defaults.Cookie.ipbPassHash).rawValue.isEmpty, - CookiesUtil.get(for: url, key: Defaults.Cookie.igneous).rawValue.isEmpty - else { return } +// MARK: TouchHandler +final class TouchHandler: NSObject, UIGestureRecognizerDelegate { + static let shared = TouchHandler() + var currentPoint: CGPoint? - store.dispatch(.fetchIgneous) + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + currentPoint = touch.location(in: touch.window) + return false } } - -// MARK: Configuration private extension EhPandaApp { func addTouchHandler() { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { @@ -86,77 +58,4 @@ private extension EhPandaApp { DeviceUtil.keyWindow?.addGestureRecognizer(tapGesture) } } - func configureLogging() { - var file = FileDestination() - var console = ConsoleDestination() - let format = [ - "$Dyyyy-MM-dd HH:mm:ss.SSS$d", - "$C$L$c $N.$F:$l - $M $X" - ].joined(separator: " ") - - file.format = format - console.format = format - configure(file: &file) - configure(console: &console) - - Logger.addDestination(file) - #if DEBUG - guard !AppUtil.isUnitTesting else { return } - Logger.addDestination(console) - #endif - } - func configure(file: inout FileDestination) { - file.logFileAmount = 10 - file.calendar = Calendar(identifier: .gregorian) - file.logFileURL = FileUtil.logsDirectoryURL? - .appendingPathComponent(Defaults.FilePath.ehpandaLog) - } - func configure(console: inout ConsoleDestination) { - console.calendar = Calendar(identifier: .gregorian) - #if DEBUG - console.asynchronously = false - #endif - console.levelColor.verbose = "😪" - console.levelColor.warning = "⚠️" - console.levelColor.error = "‼️" - console.levelColor.debug = "🐛" - console.levelColor.info = "📖" - } - - func configureWebImage() { - AppUtil.configureKingfisher(bypassesSNIFiltering: setting.bypassesSNIFiltering) - } - func configureDomainFronting() { - if setting.bypassesSNIFiltering { - URLProtocol.registerClass(DFURLProtocol.self) - } - } - func configureIgnoreOffensive() { - CookiesUtil.set(for: Defaults.URL.ehentai.safeURL(), key: "nw", value: "1") - CookiesUtil.set(for: Defaults.URL.exhentai.safeURL(), key: "nw", value: "1") - } -} - -// MARK: AppDelegate -class AppDelegate: UIResponder, UIApplicationDelegate { - static var orientationLock: UIInterfaceOrientationMask = - DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] - - func application( - _ application: UIApplication, - supportedInterfaceOrientationsFor window: UIWindow? - ) -> UIInterfaceOrientationMask { AppDelegate.orientationLock } -} - -final class TouchHandler: NSObject, UIGestureRecognizerDelegate { - static let shared = TouchHandler() - var currentPoint: CGPoint? - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldReceive touch: UITouch - ) -> Bool { - currentPoint = touch.location(in: touch.window) - return false - } } diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/AppIcon_Default@2x.png b/EhPanda/App/Icons/AppIcon_Default@2x.png similarity index 100% rename from EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/AppIcon_Default@2x.png rename to EhPanda/App/Icons/AppIcon_Default@2x.png diff --git a/EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/AppIcon_Default@3x.png b/EhPanda/App/Icons/AppIcon_Default@3x.png similarity index 100% rename from EhPanda/App/Assets.xcassets/Icons/AppIcon_Default.imageset/AppIcon_Default@3x.png rename to EhPanda/App/Icons/AppIcon_Default@3x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe@2x.png b/EhPanda/App/Icons/AppIcon_Ukiyoe@2x.png new file mode 100644 index 00000000..54c7c4d2 Binary files /dev/null and b/EhPanda/App/Icons/AppIcon_Ukiyoe@2x.png differ diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe@3x.png b/EhPanda/App/Icons/AppIcon_Ukiyoe@3x.png new file mode 100644 index 00000000..777242bc Binary files /dev/null and b/EhPanda/App/Icons/AppIcon_Ukiyoe@3x.png differ diff --git a/EhPanda/App/Info.plist b/EhPanda/App/Info.plist index 9c0b08c6..f8d08a92 100644 --- a/EhPanda/App/Info.plist +++ b/EhPanda/App/Info.plist @@ -8,48 +8,60 @@ $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) - CFBundleIcons - - CFBundleAlternateIcons - - Normal - - CFBundleIconFiles - - Normal - - - Weird - - CFBundleIconFiles - - Weird - - - - - CFBundleIcons~ipad - - CFBundleAlternateIcons - - Normal - - CFBundleIconFiles - - Normal-ipad - Normal-ipad-pro - - - Weird - - CFBundleIconFiles - - Weird-ipad - Weird-ipad-pro - - - - + CFBundleIcons + + CFBundleAlternateIcons + + AppIcon_Default + + CFBundleIconFiles + + AppIcon_Default + + + AppIcon_Ukiyoe + + CFBundleIconFiles + + AppIcon_Ukiyoe + + + + CFBundlePrimaryIcon + + CFBundleIconFiles + + UIPrerenderedIcon + + + + CFBundleIcons~ipad + + CFBundleAlternateIcons + + AppIcon_Default + + CFBundleIconFiles + + AppIcon_Default + + + AppIcon_Ukiyoe + + CFBundleIconFiles + + AppIcon_Ukiyoe + + + + CFBundlePrimaryIcon + + CFBundleIconFiles + + UIPrerenderedIcon + + + CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion diff --git a/EhPanda/App/Tools/AppEnvStorage.swift b/EhPanda/App/Tools/AppEnvStorage.swift deleted file mode 100644 index 006f33c0..00000000 --- a/EhPanda/App/Tools/AppEnvStorage.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// AppEnvStorage.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/07/10. -// - -@propertyWrapper -struct AppEnvStorage { - private var key: String - private var value: T? - - private var appEnv: AppEnv { - PersistenceController.fetchAppEnvNonNil() - } - - private var fetchedValue: T! { - let mirror = Mirror(reflecting: appEnv) - for child in mirror.children where child.label == key { - if let value = child.value as? T { - return value - } - } - Logger.error("Failed in force downcasting to generic type...") - return nil - } - - var wrappedValue: T { - get { - value ?? fetchedValue - } - set { - value = newValue - PersistenceController.update { appEnvMO in - appEnvMO.setValue(newValue.toData(), forKeyPath: key) - } - } - } - - init(type: T.Type, key: String? = nil) { - if let key = key { - self.key = key - } else { - self.key = String(describing: type).lowercased() - } - value = fetchedValue - } -} diff --git a/EhPanda/App/Tools/Clients/AppDelegateClient.swift b/EhPanda/App/Tools/Clients/AppDelegateClient.swift new file mode 100644 index 00000000..857cf639 --- /dev/null +++ b/EhPanda/App/Tools/Clients/AppDelegateClient.swift @@ -0,0 +1,40 @@ +// +// AppDelegateClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import SwiftUI +import ComposableArchitecture + +struct AppDelegateClient { + let setOrientation: (UIInterfaceOrientation) -> Effect + let setOrientationMask: (UIInterfaceOrientationMask) -> Effect +} + +extension AppDelegateClient { + static let live: Self = .init( + setOrientation: { orientation in + .fireAndForget { + UIDevice.current.setValue(orientation.rawValue, forKey: "orientation") + UINavigationController.attemptRotationToDeviceOrientation() + } + }, + setOrientationMask: { mask in + .fireAndForget { + AppDelegate.orientationMask = mask + } + } + ) + + func setPortraitOrientation() -> Effect { + setOrientation(.portrait) + } + func setAllOrientationMask() -> Effect { + setOrientationMask([.all]) + } + func setPortraitOrientationMask() -> Effect { + setOrientationMask([.portrait, .portraitUpsideDown]) + } +} diff --git a/EhPanda/App/Tools/Clients/AuthorizationClient.swift b/EhPanda/App/Tools/Clients/AuthorizationClient.swift new file mode 100644 index 00000000..485345af --- /dev/null +++ b/EhPanda/App/Tools/Clients/AuthorizationClient.swift @@ -0,0 +1,40 @@ +// +// AuthorizationClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/03. +// + +import Combine +import LocalAuthentication +import ComposableArchitecture + +struct AuthorizationClient { + let passcodeNotSet: () -> Bool + let localAuthroize: (String) -> Effect +} + +extension AuthorizationClient { + static let live: Self = .init( + passcodeNotSet: { + var error: NSError? + return !LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) + }, + localAuthroize: { reason in + Future { promise in + let context = LAContext() + var error: NSError? + + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { isSuccess, _ in + promise(.success(isSuccess)) + } + } else { + promise(.success(false)) + } + } + .eraseToAnyPublisher() + .eraseToEffect() + } + ) +} diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift new file mode 100644 index 00000000..a0b13580 --- /dev/null +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -0,0 +1,41 @@ +// +// ClipboardClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/19. +// + +import SwiftUI +import ComposableArchitecture + +struct ClipboardClient { + let url: () -> URL? + let changeCount: () -> Int + let saveText: (String) -> Effect + let saveImage: (UIImage) -> Effect +} + +extension ClipboardClient { + static let live: Self = .init( + url: { + if UIPasteboard.general.hasURLs { + return UIPasteboard.general.url + } else { + return URL(string: UIPasteboard.general.string ?? "") + } + }, + changeCount: { + UIPasteboard.general.changeCount + }, + saveText: { value in + .fireAndForget { + UIPasteboard.general.string = value + } + }, + saveImage: { value in + .fireAndForget { + UIPasteboard.general.image = value + } + } + ) +} diff --git a/EhPanda/App/Tools/Clients/CookiesClient.swift b/EhPanda/App/Tools/Clients/CookiesClient.swift new file mode 100644 index 00000000..ad09af2a --- /dev/null +++ b/EhPanda/App/Tools/Clients/CookiesClient.swift @@ -0,0 +1,245 @@ +// +// CookiesClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import Foundation +import ComposableArchitecture + +struct CookiesClient { + let clearAll: () -> Effect + let getCookie: (URL, String) -> CookieValue + private let removeCookie: (URL, String) -> Void + private let checkExistence: (URL, String) -> Bool + private let initializeCookie: (HTTPCookie, String) -> HTTPCookie +} + +extension CookiesClient { + static let live: Self = .init( + clearAll: { + .fireAndForget { + if let historyCookies = HTTPCookieStorage.shared.cookies { + historyCookies.forEach { + HTTPCookieStorage.shared.deleteCookie($0) + } + } + } + }, + getCookie: { url, key in + var value = CookieValue( + rawValue: "", localizedString: R.string.localizable.structCookieValueLocalizedStringNone() + ) + 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 { + value = CookieValue( + rawValue: "", localizedString: R.string.localizable.structCookieValueLocalizedStringExpired() + ) + return + } + guard cookie.value != Defaults.Cookie.mystery else { + value = CookieValue( + rawValue: cookie.value, localizedString: + R.string.localizable.structCookieValueLocalizedStringMystery() + ) + return + } + value = CookieValue(rawValue: cookie.value, localizedString: "") + } + + return value + }, + removeCookie: { url, key in + if let cookies = HTTPCookieStorage.shared.cookies(for: url) { + cookies.forEach { cookie in + guard cookie.name == key else { return } + HTTPCookieStorage.shared.deleteCookie(cookie) + } + } + }, + checkExistence: { url, key in + if let cookies = HTTPCookieStorage.shared.cookies(for: url) { + var existence: HTTPCookie? + cookies.forEach { cookie in + guard cookie.name == key else { return } + existence = cookie + } + return existence != nil + } else { + return false + } + }, + initializeCookie: { cookie, value in + var properties = cookie.properties + properties?[.value] = value + return HTTPCookie(properties: properties ?? [:]) ?? HTTPCookie() + } + ) +} + +// MARK: Foundation +extension CookiesClient { + private func setCookie( + for url: URL, key: String, value: String, path: String = "/", + expiresTime: TimeInterval = .init(60 * 60 * 24 * 365) + ) { + let expiredDate = Date(timeIntervalSinceNow: expiresTime) + let properties: [HTTPCookiePropertyKey: Any] = [ + .path: path, .name: key, .value: value, + .originURL: url, .expires: expiredDate + ] + if let cookie = HTTPCookie(properties: properties) { + HTTPCookieStorage.shared.setCookie(cookie) + } + } + func editCookie(for url: URL, key: String, value: String) { + var newCookie: HTTPCookie? + if let cookies = HTTPCookieStorage.shared.cookies(for: url) { + cookies.forEach { cookie in + guard cookie.name == key else { return } + newCookie = initializeCookie(cookie, value) + removeCookie(url, key) + } + } + guard let cookie = newCookie else { return } + HTTPCookieStorage.shared.setCookie(cookie) + } + func setOrEditCookie(for url: URL, key: String, value: String) -> Effect { + .fireAndForget { + if checkExistence(url, key) { + editCookie(for: url, key: key, value: value) + } else { + setCookie(for: url, key: key, value: value) + } + } + } +} + +// MARK: Accessor +extension CookiesClient { + var didLogin: Bool { + CookiesUtil.didLogin + } + var apiuid: String { + getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue + } + var isSameAccount: Bool { + let ehUID = getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbMemberId).rawValue + let exUID = getCookie(Defaults.URL.exhentai, Defaults.Cookie.ipbMemberId).rawValue + if !ehUID.isEmpty && !exUID.isEmpty { return ehUID == exUID } else { return false } + } + var shouldFetchIgneous: Bool { + let url = Defaults.URL.exhentai + return !getCookie(url, Defaults.Cookie.ipbMemberId).rawValue.isEmpty + && !getCookie(url, Defaults.Cookie.ipbPassHash).rawValue.isEmpty + && getCookie(url, Defaults.Cookie.igneous).rawValue.isEmpty + } + func removeYay() -> Effect { + .fireAndForget { + removeCookie(Defaults.URL.exhentai, Defaults.Cookie.yay) + } + } + func ignoreOffensive() -> Effect { + .merge( + setOrEditCookie(for: Defaults.URL.ehentai, key: Defaults.Cookie.ignoreOffensive, value: "1"), + setOrEditCookie(for: Defaults.URL.exhentai, key: Defaults.Cookie.ignoreOffensive, value: "1") + ) + } + func fulfillAnotherHostField() -> Effect { + let ehURL = Defaults.URL.ehentai + let exURL = Defaults.URL.exhentai + let memberIdKey = Defaults.Cookie.ipbMemberId + let passHashKey = Defaults.Cookie.ipbPassHash + let ehMemberId = getCookie(ehURL, memberIdKey).rawValue + let ehPassHash = getCookie(ehURL, passHashKey).rawValue + let exMemberId = getCookie(exURL, memberIdKey).rawValue + let exPassHash = getCookie(exURL, passHashKey).rawValue + + if !ehMemberId.isEmpty && !ehPassHash.isEmpty && (exMemberId.isEmpty || exPassHash.isEmpty) { + return .merge( + setOrEditCookie(for: exURL, key: memberIdKey, value: ehMemberId), + setOrEditCookie(for: exURL, key: passHashKey, value: ehPassHash) + ) + } else if !exMemberId.isEmpty && !exPassHash.isEmpty && (ehMemberId.isEmpty || ehPassHash.isEmpty) { + return .merge( + setOrEditCookie(for: ehURL, key: memberIdKey, value: exMemberId), + setOrEditCookie(for: ehURL, key: passHashKey, value: exPassHash) + ) + } else { + return .none + } + } + func loadCookiesState(host: GalleryHost) -> CookiesState { + let igneousKey = Defaults.Cookie.igneous + let memberIDKey = Defaults.Cookie.ipbMemberId + let passHashKey = Defaults.Cookie.ipbPassHash + let igneous = getCookie(host.url, igneousKey) + let memberID = getCookie(host.url, memberIDKey) + let passHash = getCookie(host.url, passHashKey) + return .init( + host: host, + igneous: .init(key: igneousKey, value: igneous, editingText: igneous.rawValue), + memberID: .init(key: memberIDKey, value: memberID, editingText: memberID.rawValue), + passHash: .init(key: passHashKey, value: passHash, editingText: passHash.rawValue) + ) + } + func getCookiesDescription(host: GalleryHost) -> String { + var dictionary = [String: String]() + [Defaults.Cookie.igneous, Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash].forEach { key in + let cookieValue = getCookie(host.url, key) + if !cookieValue.rawValue.isEmpty { + dictionary[key] = cookieValue.rawValue + } + } + return dictionary.description + } +} + +// MARK: SetCookies +extension CookiesClient { + func setCookies(state: CookiesState) -> Effect { + let effects: [Effect] = state.allCases.map { subState in + setOrEditCookie(for: state.host.url, key: subState.key, value: subState.editingText) + } + return effects.isEmpty ? .none : .merge(effects) + } + func setCredentials(response: HTTPURLResponse) -> Effect { + .fireAndForget { + guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } + setString.components(separatedBy: ", ") + .flatMap { $0.components(separatedBy: "; ") }.forEach { value in + [Defaults.URL.ehentai, Defaults.URL.exhentai].forEach { url in + [ + Defaults.Cookie.ipbMemberId, + Defaults.Cookie.ipbPassHash, + Defaults.Cookie.igneous + ].forEach { key in + guard !(url == Defaults.URL.ehentai && key == Defaults.Cookie.igneous), + let range = value.range(of: "\(key)=") else { return } + setCookie(for: url, key: key, value: String(value[range.upperBound...])) + } + } + } + } + } + func setSkipServer(response: HTTPURLResponse) -> Effect { + .fireAndForget { + guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } + setString.components(separatedBy: ", ") + .flatMap { $0.components(separatedBy: "; ") } + .forEach { value in + let key = Defaults.Cookie.skipServer + if let range = value.range(of: "\(key)=") { + setCookie( + for: Defaults.URL.host, key: key, + value: String(value[range.upperBound...]), path: "/s/" + ) + } + } + } + } +} diff --git a/EhPanda/App/Tools/Clients/DFClient.swift b/EhPanda/App/Tools/Clients/DFClient.swift new file mode 100644 index 00000000..e745b9fd --- /dev/null +++ b/EhPanda/App/Tools/Clients/DFClient.swift @@ -0,0 +1,31 @@ +// +// DFClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import Kingfisher +import ComposableArchitecture + +struct DFClient { + let setActive: (Bool) -> Effect +} + +extension DFClient { + static let live: Self = .init( + setActive: { newValue in + .fireAndForget { + if newValue { + URLProtocol.registerClass(DFURLProtocol.self) + } else { + URLProtocol.unregisterClass(DFURLProtocol.self) + } + // Kingfisher + let config = KingfisherManager.shared.downloader.sessionConfiguration + config.protocolClasses = newValue ? [DFURLProtocol.self] : nil + KingfisherManager.shared.downloader.sessionConfiguration = config + } + } + ) +} diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift new file mode 100644 index 00000000..0a4173ac --- /dev/null +++ b/EhPanda/App/Tools/Clients/DatabaseClient.swift @@ -0,0 +1,523 @@ +// +// DatabaseClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import SwiftUI +import Combine +import CoreData +import ComposableArchitecture + +struct DatabaseClient { + let prepareDatabase: () -> Effect, Never> + let dropDatabase: () -> Effect, Never> + private let saveContext: () -> Void + private let materializedObjects: (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] +} + +extension DatabaseClient { + static let live: Self = .init( + prepareDatabase: { + Future { promise in + PersistenceController.shared.prepare(completion: promise) + } + .eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .catchToEffect() + }, + dropDatabase: { + Future { promise in + PersistenceController.shared.rebuild(completion: promise) + } + .eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .catchToEffect() + }, + saveContext: { + let context = PersistenceController.shared.container.viewContext + AppUtil.dispatchMainSync { + guard context.hasChanges else { return } + do { + try context.save() + } catch { + Logger.error(error) + fatalError("Unresolved error \(error)") + } + } + }, + materializedObjects: { context, predicate in + var objects = [NSManagedObject]() + for object in context.registeredObjects where !object.isFault { + guard object.entity.attributesByName.keys.contains("gid"), + predicate.evaluate(with: object) + else { continue } + objects.append(object) + } + return objects + } + ) +} + +// MARK: Foundation +extension DatabaseClient { + private func batchFetch( + entityType: MO.Type, fetchLimit: Int = 0, predicate: NSPredicate? = nil, + findBeforeFetch: Bool = true, sortDescriptors: [NSSortDescriptor]? = nil + ) -> [MO] { + var results = [MO]() + let context = PersistenceController.shared.container.viewContext + AppUtil.dispatchMainSync { + if findBeforeFetch, let predicate = predicate { + if let objects = materializedObjects(context, predicate) as? [MO], !objects.isEmpty { + results = objects + return + } + } + let request = NSFetchRequest( + entityName: String(describing: entityType) + ) + request.predicate = predicate + request.fetchLimit = fetchLimit + request.sortDescriptors = sortDescriptors + results = (try? context.fetch(request)) ?? [] + } + return results + } + + private func fetch( + entityType: MO.Type, predicate: NSPredicate? = nil, + findBeforeFetch: Bool = true, commitChanges: ((MO?) -> Void)? = nil + ) -> MO? { + let managedObject = batchFetch( + entityType: entityType, fetchLimit: 1, + predicate: predicate, findBeforeFetch: findBeforeFetch + ).first + commitChanges?(managedObject) + return managedObject + } + + private func fetchOrCreate( + entityType: MO.Type, predicate: NSPredicate? = nil, + commitChanges: ((MO?) -> Void)? = nil + ) -> MO { + if let storedMO = fetch( + entityType: entityType, predicate: predicate, commitChanges: commitChanges + ) { + return storedMO + } else { + let newMO = MO(context: PersistenceController.shared.container.viewContext) + commitChanges?(newMO) + saveContext() + return newMO + } + } + + private func batchUpdate( + entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ([MO]) -> Void + ) { + commitChanges(batchFetch( + entityType: entityType, + predicate: predicate, + findBeforeFetch: false + )) + saveContext() + } + private func update( + entityType: MO.Type, predicate: NSPredicate? = nil, + createIfNil: Bool = false, commitChanges: (MO) -> Void + ) { + AppUtil.dispatchMainSync { + let storedMO: MO? + if createIfNil { + storedMO = fetchOrCreate(entityType: entityType, predicate: predicate) + } else { + storedMO = fetch(entityType: entityType, predicate: predicate) + } + if let storedMO = storedMO { + commitChanges(storedMO) + saveContext() + } + } + } +} + +// MARK: GalleryIdentifiable +extension DatabaseClient { + private func fetch( + entityType: MO.Type, gid: String, + findBeforeFetch: Bool = true, + commitChanges: ((MO?) -> Void)? = nil + ) -> MO? { + fetch( + entityType: entityType, predicate: NSPredicate(format: "gid == %@", gid), + findBeforeFetch: findBeforeFetch, commitChanges: commitChanges + ) + } + private func fetchOrCreate(entityType: MO.Type, gid: String) -> MO { + fetchOrCreate( + entityType: entityType, + predicate: NSPredicate(format: "gid == %@", gid), + commitChanges: { $0?.gid = gid } + ) + } + private func update( + entityType: MO.Type, gid: String, + createIfNil: Bool = false, + commitChanges: @escaping ((MO) -> Void) + ) { + AppUtil.dispatchMainSync { + let storedMO: MO? + if createIfNil { + storedMO = fetchOrCreate(entityType: entityType, gid: gid) + } else { + storedMO = fetch(entityType: entityType, gid: gid) + } + if let storedMO = storedMO { + commitChanges(storedMO) + saveContext() + } + } + } +} + +// MARK: Fetch +extension DatabaseClient { + func fetchGallery(gid: String) -> Gallery? { + guard gid.isValidGID else { return nil } + var entity: Gallery? + AppUtil.dispatchMainSync { + entity = fetch(entityType: GalleryMO.self, gid: gid)?.toEntity() + } + return entity + } + func fetchGalleryDetail(gid: String) -> GalleryDetail? { + guard gid.isValidGID else { return nil } + var entity: GalleryDetail? + AppUtil.dispatchMainSync { + entity = fetch(entityType: GalleryDetailMO.self, gid: gid)?.toEntity() + } + return entity + } + func fetchAppEnv() -> Effect { + Future { promise in + DispatchQueue.main.async { + promise(.success(fetchOrCreate(entityType: AppEnvMO.self).toEntity())) + } + } + .eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .eraseToEffect() + } + func fetchAppEnvSynchronously() -> AppEnv { + fetchOrCreate(entityType: AppEnvMO.self).toEntity() + } + func fetchGalleryState(gid: String) -> Effect { + guard gid.isValidGID else { return .none } + return Future { promise in + DispatchQueue.main.async { + promise(.success( + fetchOrCreate(entityType: GalleryStateMO.self, gid: gid).toEntity() + )) + } + } + .eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .eraseToEffect() + } + func fetchHistoryGalleries(fetchLimit: Int = 0) -> Effect<[Gallery], Never> { + Future { promise in + DispatchQueue.main.async { + let predicate = NSPredicate(format: "lastOpenDate != nil") + let sortDescriptor = NSSortDescriptor( + keyPath: \GalleryMO.lastOpenDate, ascending: false + ) + let galleries = batchFetch( + entityType: GalleryMO.self, fetchLimit: fetchLimit, predicate: predicate, + findBeforeFetch: false, sortDescriptors: [sortDescriptor] + ) + .map { $0.toEntity() } + promise(.success(galleries)) + } + } + .eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .eraseToEffect() + } +} +// MARK: FetchAccessor +extension DatabaseClient { + func fetchFilterSynchronously(range: FilterRange) -> Filter { + switch range { + case .search: + return fetchAppEnvSynchronously().searchFilter + case .global: + return fetchAppEnvSynchronously().globalFilter + case .watched: + return fetchAppEnvSynchronously().watchedFilter + } + } + func fetchHistoryKeywords() -> Effect<[String], Never> { + fetchAppEnv().map(\.historyKeywords) + } + func fetchQuickSearchWords() -> Effect<[QuickSearchWord], Never> { + fetchAppEnv().map(\.quickSearchWords) + } + func fetchGalleryPreviewURLs(gid: String) -> Effect<[Int: URL], Never> { + guard gid.isValidGID else { return .none } + return fetchGalleryState(gid: gid).map(\.previewURLs) + } +} + +// MARK: UpdateGallery +extension DatabaseClient { + func updateGallery(gid: String, key: String, value: Any?) -> Effect { + guard gid.isValidGID else { return .none } + return .fireAndForget { + DispatchQueue.main.async { + update( + entityType: GalleryMO.self, gid: gid, createIfNil: true, + commitChanges: { $0.setValue(value, forKeyPath: key) } + ) + } + } + } + func updateLastOpenDate(gid: String, date: Date = .now) -> Effect { + guard gid.isValidGID else { return .none } + return updateGallery(gid: gid, key: "lastOpenDate", value: date) + } + func clearHistoryGalleries() -> Effect { + .fireAndForget { + DispatchQueue.main.async { + let predicate = NSPredicate(format: "lastOpenDate != nil") + batchUpdate(entityType: GalleryMO.self, predicate: predicate) { galleryMOs in + galleryMOs.forEach { galleryMO in + galleryMO.lastOpenDate = nil + } + } + } + } + } + func cacheGalleries(_ galleries: [Gallery]) -> Effect { + .fireAndForget { + DispatchQueue.main.async { + for gallery in galleries.filter({ $0.id.isValidGID }) { + let storedMO = fetch( + entityType: GalleryMO.self, gid: gallery.gid + ) { managedObject in + managedObject?.category = gallery.category.rawValue + managedObject?.coverURL = gallery.coverURL + managedObject?.galleryURL = gallery.galleryURL + if let language = gallery.language { + managedObject?.language = language.rawValue + } + // managedObject?.lastOpenDate = gallery.lastOpenDate + managedObject?.pageCount = Int64(gallery.pageCount) + managedObject?.postedDate = gallery.postedDate + managedObject?.rating = gallery.rating + managedObject?.tagStrings = gallery.tagStrings.toData() + managedObject?.title = gallery.title + managedObject?.token = gallery.token + if let uploader = gallery.uploader { + managedObject?.uploader = uploader + } + } + if storedMO == nil { + gallery.toManagedObject(in: PersistenceController.shared.container.viewContext) + } + } + saveContext() + } + } + } +} + +// MARK: UpdateGalleryDetail +extension DatabaseClient { + func cacheGalleryDetail(_ detail: GalleryDetail) -> Effect { + guard detail.gid.isValidGID else { return .none } + return .fireAndForget { + DispatchQueue.main.async { + let storedMO = fetch( + entityType: GalleryDetailMO.self, gid: detail.gid + ) { managedObject in + managedObject?.archiveURL = detail.archiveURL + managedObject?.category = detail.category.rawValue + managedObject?.coverURL = detail.coverURL + managedObject?.isFavorited = detail.isFavorited + managedObject?.visibility = detail.visibility.toData() + managedObject?.jpnTitle = detail.jpnTitle + managedObject?.language = detail.language.rawValue + managedObject?.favoritedCount = Int64(detail.favoritedCount) + managedObject?.pageCount = Int64(detail.pageCount) + managedObject?.parentURL = detail.parentURL + managedObject?.postedDate = detail.postedDate + managedObject?.rating = detail.rating + managedObject?.userRating = detail.userRating + managedObject?.ratingCount = Int64(detail.ratingCount) + managedObject?.sizeCount = detail.sizeCount + managedObject?.sizeType = detail.sizeType + managedObject?.title = detail.title + managedObject?.torrentCount = Int64(detail.torrentCount) + managedObject?.uploader = detail.uploader + } + if storedMO == nil { + detail.toManagedObject(in: PersistenceController.shared.container.viewContext) + } + saveContext() + } + } + } +} + +// MARK: UpdateGalleryState +extension DatabaseClient { + func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) -> Effect { + guard gid.isValidGID else { return .none } + return .fireAndForget { + DispatchQueue.main.async { + update( + entityType: GalleryStateMO.self, gid: gid, createIfNil: true, + commitChanges: commitChanges + ) + } + } + } + func updateGalleryState(gid: String, key: String, value: Any?) -> Effect { + guard gid.isValidGID else { return .none } + return updateGalleryState(gid: gid) { stateMO in + stateMO.setValue(value, forKeyPath: key) + } + } + func updateGalleryTags(gid: String, tags: [GalleryTag]) -> Effect { + guard gid.isValidGID else { return .none } + return updateGalleryState(gid: gid, key: "tags", value: tags.toData()) + } + func updatePreviewConfig(gid: String, config: PreviewConfig) -> Effect { + guard gid.isValidGID else { return .none } + return updateGalleryState(gid: gid, key: "previewConfig", value: config.toData()) + } + func updateReadingProgress(gid: String, progress: Int) -> Effect { + guard gid.isValidGID else { return .none } + return updateGalleryState(gid: gid, key: "readingProgress", value: Int64(progress)) + } + func updateComments(gid: String, comments: [GalleryComment]) -> Effect { + guard gid.isValidGID else { return .none } + return updateGalleryState(gid: gid, key: "comments", value: comments.toData()) + } + + func removeImageURLs() -> Effect { + .fireAndForget { + DispatchQueue.main.async { + batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in + galleryStateMOs.forEach { galleryStateMO in + galleryStateMO.imageURLs = nil + galleryStateMO.previewURLs = nil + galleryStateMO.thumbnailURLs = nil + galleryStateMO.originalImageURLs = nil + } + } + } + } + } + func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) -> Effect { + guard gid.isValidGID else { return .none } + return updateGalleryState(gid: gid) { galleryStateMO in + update(gid: gid, storedData: &galleryStateMO.thumbnailURLs, new: thumbnailURLs) + } + } + func updateImageURLs( + gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL] + ) -> Effect { + guard gid.isValidGID else { return .none } + return updateGalleryState(gid: gid) { galleryStateMO in + update(gid: gid, storedData: &galleryStateMO.imageURLs, new: imageURLs) + update(gid: gid, storedData: &galleryStateMO.originalImageURLs, new: originalImageURLs) + } + } + func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) -> Effect { + guard gid.isValidGID else { return .none } + 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 { + func updateAppEnv(key: String, value: Any?) -> Effect { + .fireAndForget { + DispatchQueue.main.async { + update( + entityType: AppEnvMO.self, createIfNil: true, + commitChanges: { $0.setValue(value, forKeyPath: key) } + ) + } + } + } + func updateSetting(_ setting: Setting) -> Effect { + updateAppEnv(key: "setting", value: setting.toData()) + } + func updateFilter(_ filter: Filter, range: FilterRange) -> Effect { + let key: String + switch range { + case .search: + key = "searchFilter" + case .global: + key = "watchedFilter" + case .watched: + key = "globalFilter" + } + return updateAppEnv(key: key, value: filter.toData()) + } + func updateTagTranslator(_ tagTranslator: TagTranslator) -> Effect { + updateAppEnv(key: "tagTranslator", value: tagTranslator.toData()) + } + func updateUser(_ user: User) -> Effect { + updateAppEnv(key: "user", value: user.toData()) + } + func updateHistoryKeywords(_ keywords: [String]) -> Effect { + updateAppEnv(key: "historyKeywords", value: keywords.toData()) + } + func updateQuickSearchWords(_ words: [QuickSearchWord]) -> Effect { + updateAppEnv(key: "quickSearchWords", value: words.toData()) + } + + // Update User + func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) -> Effect { + fetchAppEnv().map(\.user) + .map { (user: User) -> User in + var user = user + commitChanges(&user) + return user + } + .flatMap(updateUser) + .eraseToEffect() + } + func updateGreeting(_ greeting: Greeting) -> Effect { + updateUserProperty { user in + user.greeting = greeting + } + } + func updateGalleryFunds(galleryPoints: String, credits: String) -> Effect { + updateUserProperty { user in + user.credits = credits + user.galleryPoints = galleryPoints + } + } +} diff --git a/EhPanda/App/Tools/Clients/DeviceClient.swift b/EhPanda/App/Tools/Clients/DeviceClient.swift new file mode 100644 index 00000000..710db876 --- /dev/null +++ b/EhPanda/App/Tools/Clients/DeviceClient.swift @@ -0,0 +1,32 @@ +// +// DeviceClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import SwiftUI + +struct DeviceClient { + let isPad: () -> Bool + let absWindowW: () -> Double + let absWindowH: () -> Double + let touchPoint: () -> CGPoint? +} + +extension DeviceClient { + static let live: Self = .init( + isPad: { + DeviceUtil.isPad + }, + absWindowW: { + DeviceUtil.absWindowW + }, + absWindowH: { + DeviceUtil.absWindowH + }, + touchPoint: { + TouchHandler.shared.currentPoint + } + ) +} diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift new file mode 100644 index 00000000..b92585ea --- /dev/null +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -0,0 +1,104 @@ +// +// FileClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/03. +// + +import Combine +import ComposableArchitecture + +struct FileClient { + let createFile: (String, Data?) -> Bool + let fetchLogs: () -> Effect, Never> + let deleteLog: (String) -> Effect, Never> + let importTagTranslator: (URL) -> Effect, Never> +} + +extension FileClient { + static let live: Self = .init( + createFile: { path, data in + FileManager.default.createFile(atPath: path, contents: data, attributes: nil) + }, + fetchLogs: { + Future { promise in + DispatchQueue.global().async { + guard let path = FileUtil.logsDirectoryURL?.path, + let enumerator = FileManager.default.enumerator(atPath: path), + let fileNames = (enumerator.allObjects as? [String])? + .filter({ $0.contains(Defaults.FilePath.ehpandaLog) }) + else { + promise(.failure(.notFound)) + return + } + + let logs: [Log] = fileNames.compactMap { name in + guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(name), + let content = try? String(contentsOf: fileURL) + else { return nil } + + return Log( + fileName: name, contents: content + .components(separatedBy: "\n") + .filter({ !$0.isEmpty }) + ) + } + .sorted() + promise(.success(logs)) + } + } + .eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .catchToEffect() + }, + deleteLog: { fileName in + Future { promise in + guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(fileName) + else { + promise(.failure(.notFound)) + return + } + + try? FileManager.default.removeItem(at: fileURL) + + if FileManager.default.fileExists(atPath: fileURL.path) { + promise(.failure(.unknown)) + } + promise(.success(fileName)) + } + .eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .catchToEffect() + }, + importTagTranslator: { url in + Future { promise in + DispatchQueue.global().async { + guard let data = try? Data(contentsOf: url), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + promise(.failure(.parseFailed)) + return + } + let translations = Parser.parseTranslations(dict: dict) + guard !translations.isEmpty else { + promise(.failure(.parseFailed)) + return + } + promise(.success(.init(hasCustomTranslations: true, contents: translations))) + } + } + .eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .catchToEffect() + } + ) + + func saveTorrent(hash: String, data: Data) -> URL? { + if let cachesDirectory = FileUtil.cachesDirectory { + let torrentDirectory = cachesDirectory.appendingPathComponent("\(hash).torrent") + return createFile(torrentDirectory.path, data) ? torrentDirectory : nil + } else { + return nil + } + } +} diff --git a/EhPanda/App/Tools/Clients/HapticClient.swift b/EhPanda/App/Tools/Clients/HapticClient.swift new file mode 100644 index 00000000..7287c7a9 --- /dev/null +++ b/EhPanda/App/Tools/Clients/HapticClient.swift @@ -0,0 +1,29 @@ +// +// HapticClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import SwiftUI +import ComposableArchitecture + +struct HapticClient { + let generateFeedback: (UIImpactFeedbackGenerator.FeedbackStyle) -> Effect + let generateNotificationFeedback: (UINotificationFeedbackGenerator.FeedbackType) -> Effect +} + +extension HapticClient { + static let live: Self = .init( + generateFeedback: { style in + .fireAndForget { + HapticUtil.generateFeedback(style: style) + } + }, + generateNotificationFeedback: { style in + .fireAndForget { + HapticUtil.generateNotificationFeedback(style: style) + } + } + ) +} diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift new file mode 100644 index 00000000..27b58d3a --- /dev/null +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -0,0 +1,95 @@ +// +// ImageClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/23. +// + +import SwiftUI +import Combine +import Kingfisher +import ComposableArchitecture + +struct ImageClient { + let prefetchImages: ([URL]) -> Effect + let saveImageToPhotoLibrary: (UIImage) -> Effect + let downloadImage: (URL) -> Effect, Never> + let retrieveImage: (String) -> Effect, Never> +} + +extension ImageClient { + static let live: Self = .init( + prefetchImages: { urls in + .fireAndForget { + ImagePrefetcher(urls: urls).start() + } + }, + saveImageToPhotoLibrary: { image in + Future { promise in + let imageSaver = ImageSaver { isSuccess in + promise(.success(isSuccess)) + } + imageSaver.saveImage(image) + } + .eraseToAnyPublisher() + .eraseToEffect() + }, + downloadImage: { url in + Future { promise in + KingfisherManager.shared.downloader.downloadImage(with: url, options: nil) { result in + switch result { + case .success(let result): + promise(.success(result.image)) + case .failure(let error): + promise(.failure(error)) + } + } + } + .eraseToAnyPublisher() + .catchToEffect() + }, + retrieveImage: { key in + Future { promise in + KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in + switch result { + case .success(let result): + if let image = result.image { + promise(.success(image)) + } else { + promise(.failure(AppError.notFound)) + } + case .failure(let error): + promise(.failure(error)) + } + } + } + .eraseToAnyPublisher() + .catchToEffect() + } + ) + + func fetchImage(url: URL) -> Effect, Never> { + if KingfisherManager.shared.cache.isCached(forKey: url.absoluteString) { + return retrieveImage(url.absoluteString) + } else { + return downloadImage(url) + } + } +} + +private final class ImageSaver: NSObject { + private let completion: (Bool) -> Void + + init(completion: @escaping (Bool) -> Void) { + self.completion = completion + } + + func saveImage(_ image: UIImage) { + UIImageWriteToSavedPhotosAlbum(image, self, #selector(didFinishSavingImage), nil) + } + @objc func didFinishSavingImage( + _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer + ) { + completion(error == nil) + } +} diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift new file mode 100644 index 00000000..439fa164 --- /dev/null +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -0,0 +1,86 @@ +// +// LibraryClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import SwiftUI +import Combine +import Foundation +import Kingfisher +import SwiftyBeaver +import UIImageColors +import ComposableArchitecture + +struct LibraryClient { + let initializeLogger: () -> Effect + let initializeWebImage: () -> Effect + let clearWebImageDiskCache: () -> Effect + let analyzeImageColors: (UIImage) -> Effect + let calculateWebImageDiskCacheSize: () -> Effect, Never> +} + +extension LibraryClient { + static let live: Self = .init( + initializeLogger: { + .fireAndForget { + // MARK: SwiftyBeaver + let file = FileDestination() + let console = ConsoleDestination() + let format = [ + "$Dyyyy-MM-dd HH:mm:ss.SSS$d", + "$C$L$c $N.$F:$l - $M $X" + ].joined(separator: " ") + + file.format = format + file.logFileAmount = 10 + file.calendar = Calendar(identifier: .gregorian) + file.logFileURL = FileUtil.logsDirectoryURL? + .appendingPathComponent(Defaults.FilePath.ehpandaLog) + + console.format = format + console.calendar = Calendar(identifier: .gregorian) + console.asynchronously = false + console.levelColor.verbose = "😪" + console.levelColor.warning = "⚠️" + console.levelColor.error = "‼️" + console.levelColor.debug = "🐛" + console.levelColor.info = "📖" + + SwiftyBeaver.addDestination(file) + #if DEBUG + SwiftyBeaver.addDestination(console) + #endif + } + }, + initializeWebImage: { + .fireAndForget { + let config = KingfisherManager.shared.downloader.sessionConfiguration + config.httpCookieStorage = HTTPCookieStorage.shared + KingfisherManager.shared.downloader.sessionConfiguration = config + } + }, + clearWebImageDiskCache: { + .fireAndForget { + KingfisherManager.shared.cache.clearDiskCache() + } + }, + analyzeImageColors: { image in + Future { promise in + image.getColors(quality: .lowest) { colors in + promise(.success(colors)) + } + } + .eraseToAnyPublisher() + .eraseToEffect() + }, + calculateWebImageDiskCacheSize: { + Future(KingfisherManager.shared.cache.calculateDiskStorageSize) + .eraseToAnyPublisher() + .subscribe(on: DispatchQueue.global()) + .receive(on: DispatchQueue.main) + .catchToEffect() + } + ) +} diff --git a/EhPanda/App/Tools/Clients/LoggerClient.swift b/EhPanda/App/Tools/Clients/LoggerClient.swift new file mode 100644 index 00000000..cf9023ad --- /dev/null +++ b/EhPanda/App/Tools/Clients/LoggerClient.swift @@ -0,0 +1,28 @@ +// +// LoggerClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import ComposableArchitecture + +struct LoggerClient { + let info: (Any, Any?) -> Effect + let error: (Any, Any?) -> Effect +} + +extension LoggerClient { + static let live: Self = .init( + info: { message, context in + .fireAndForget { + Logger.info(message, context: context) + } + }, + error: { message, context in + .fireAndForget { + Logger.error(message, context: context) + } + } + ) +} diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/EhPanda/App/Tools/Clients/UIApplicationClient.swift new file mode 100644 index 00000000..9d3f02f6 --- /dev/null +++ b/EhPanda/App/Tools/Clients/UIApplicationClient.swift @@ -0,0 +1,68 @@ +// +// UIApplicationClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import SwiftUI +import Combine +import ComposableArchitecture + +struct UIApplicationClient { + let openURL: (URL) -> Effect + let hideKeyboard: () -> Effect + let alternateIconName: () -> String? + let setAlternateIconName: (String?) -> Effect, Never> + let setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect +} + +extension UIApplicationClient { + static let live: Self = .init( + openURL: { url in + .fireAndForget { + UIApplication.shared.open(url, options: [:]) + } + }, + hideKeyboard: { + .fireAndForget { + UIApplication.shared.endEditing() + } + }, + alternateIconName: { + UIApplication.shared.alternateIconName + }, + setAlternateIconName: { iconName in + Future { promise in + UIApplication.shared.setAlternateIconName(iconName) { error in + if let error = error { + promise(.success(false)) + } else { + promise(.success(true)) + } + } + } + .eraseToAnyPublisher() + .catchToEffect() + }, + setUserInterfaceStyle: { userInterfaceStyle in + .fireAndForget { + (DeviceUtil.keyWindow ?? DeviceUtil.anyWindow)?.overrideUserInterfaceStyle = userInterfaceStyle + } + } + ) + func openSettings() -> Effect { + if let url = URL(string: UIApplication.openSettingsURLString) { + return openURL(url) + } + return .none + } + func openFileApp() -> Effect { + if let dirPath = FileUtil.logsDirectoryURL?.path, + let dirURL = URL(string: "shareddocuments://" + dirPath) + { + return openURL(dirURL) + } + return .none + } +} diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/EhPanda/App/Tools/Clients/URLClient.swift new file mode 100644 index 00000000..d6487e14 --- /dev/null +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -0,0 +1,69 @@ +// +// URLClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/16. +// + +import SwiftUI +import ComposableArchitecture + +struct URLClient { + let checkIfHandleable: (URL) -> Bool + let checkIfMPVURL: (URL?) -> Bool + let parseGalleryID: (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.pathComponents.count >= 4 && ["g", "s"].contains(url.pathComponents[1]) + && !url.pathComponents[2].isEmpty && !url.pathComponents[3].isEmpty + }, + checkIfMPVURL: { + guard let url = $0 else { return false } + return url.pathComponents.count >= 1 && url.pathComponents[1] == "mpv" + }, + parseGalleryID: { url in + var gid = url.pathComponents[2] + let token = url.pathComponents[3] + if let range = token.range(of: "-") { + gid = String(token[.. URL? { + guard url.scheme == "ehpanda", + let newURL = url.replaceScheme(to: "https") + else { return url } + return newURL + } + func analyzeURL(_ url: URL) -> (Bool, Int?, String?) { + guard checkIfHandleable(url) else { + return (false, nil, nil) + } + var isGalleryImageURL = false + var commentID: String? + var pageIndex: Int? + + let token = url.pathComponents[3] + if let range = token.range(of: "-") { + pageIndex = Int(token[range.upperBound...]) + isGalleryImageURL = true + } + + if let range = url.absoluteString.range(of: url.pathComponents[3] + "/") { + let commentField = String(url.absoluteString[range.upperBound...]) + if let range = commentField.range(of: "#c") { + commentID = String(commentField[range.upperBound...]) + isGalleryImageURL = false + } + } + + return (isGalleryImageURL, pageIndex, commentID) + } +} diff --git a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift new file mode 100644 index 00000000..b9f3e798 --- /dev/null +++ b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift @@ -0,0 +1,26 @@ +// +// UserDefaultsClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import ComposableArchitecture + +struct UserDefaultsClient { + let setValue: (Any, AppUserDefaults) -> Effect +} + +extension UserDefaultsClient { + static let live: Self = .init( + setValue: { value, key in + .fireAndForget { + UserDefaults.standard.set(value, forKey: key.rawValue) + } + } + ) + + func getValue(_ key: AppUserDefaults) -> T? { + UserDefaultsUtil.value(forKey: key) + } +} diff --git a/EhPanda/App/Tools/CodableExtension.swift b/EhPanda/App/Tools/CodableExtension.swift deleted file mode 100644 index 057db3c8..00000000 --- a/EhPanda/App/Tools/CodableExtension.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// CodableExtension.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/07/23. -// - -import SwiftUI -import BetterCodable - -typealias DefaultStringValue = DefaultCodable -struct DefaultStringValueStrategy: DefaultCodableStrategy { - static var defaultValue: String { "" } -} - -typealias DefaultDoubleValue = DefaultCodable -struct DefaultDoubleValueStrategy: DefaultCodableStrategy { - static var defaultValue: Double { 0 } -} - -typealias DefaultIntegerValue = DefaultCodable -struct DefaultIntegerValueStrategy: DefaultCodableStrategy { - static var defaultValue: Int { 0 } -} - -typealias DefaultColorValue = DefaultCodable -struct DefaultColorValueStrategy: DefaultCodableStrategy { - static var defaultValue: Color { .blue } -} - -typealias DefaultGalleryHost = DefaultCodable -struct DefaultGalleryHostStrategy: DefaultCodableStrategy { - static var defaultValue: GalleryHost { .ehentai } -} - -typealias DefaultListMode = DefaultCodable -struct DefaultListModeStrategy: DefaultCodableStrategy { - static var defaultValue: ListMode { DeviceUtil.isPadWidth ? .thumbnail : .detail } -} - -typealias DefaultPreferredColorScheme = DefaultCodable -struct DefaultPreferredColorSchemeStrategy: DefaultCodableStrategy { - static var defaultValue: PreferredColorScheme { .automatic } -} - -typealias DefaultAutoLockPolicy = DefaultCodable -struct DefaultAutoLockPolicyStrategy: DefaultCodableStrategy { - static var defaultValue: AutoLockPolicy { .never } -} - -typealias DefaultIconType = DefaultCodable -struct DefaultIconTypeStrategy: DefaultCodableStrategy { - static var defaultValue: IconType { .default } -} - -typealias DefaultReadingDirection = DefaultCodable -struct DefaultReadingDirectionStrategy: DefaultCodableStrategy { - static var defaultValue: ReadingDirection { .vertical } -} diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift new file mode 100644 index 00000000..bcbdd8ee --- /dev/null +++ b/EhPanda/App/Tools/Defaults.swift @@ -0,0 +1,161 @@ +// +// Defaults.swift +// EhPanda +// +// Created by 荒木辰造 on R 2/11/22. +// + +import UIKit +import Foundation + +struct Defaults { + struct FrameSize { + static let archiveGridWidth: CGFloat = + DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 + static var cardCellWidth: CGFloat { DeviceUtil.windowW * 0.8 } + static let cardCellHeight: CGFloat = Defaults.ImageSize.headerH + 20 * 2 + static var cardCellSize: CGSize { + .init(width: cardCellWidth, height: cardCellHeight) + } + static var rankingCellWidth: CGFloat { + (DeviceUtil.isPadWidth ? 0.4 : 0.7) * DeviceUtil.windowW + } + static var alertWidthFactor: Double { + DeviceUtil.isPadWidth ? 0.5 : 1.0 + } + } + struct ImageSize { + static let rowAspect: CGFloat = 8/11 + static let avatarAspect: CGFloat = 1/1 + static let headerAspect: CGFloat = 8/11 + static let previewAspect: CGFloat = 8/11 + static let contentAspect: CGFloat = 7/10 + static let webtoonMinAspect: CGFloat = 1/4 + static let webtoonIdealAspect: CGFloat = 2/3 + + static let rowW: CGFloat = rowH * rowAspect + static let rowH: CGFloat = 110 + static let avatarW: CGFloat = 100 + static let avatarH: CGFloat = 100 + 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 + } + struct Cookie { + static let yay = "yay" + static let null = "null" + static let expired = "expired" + static let mystery = "mystery" + static let ignoreOffensive = "nw" + static let selectedProfile = "sp" + static let skipServer = "skipserver" + + static let igneous = "igneous" + static let ipbMemberId = "ipb_member_id" + static let ipbPassHash = "ipb_pass_hash" + } + struct DateFormat { + static let greeting = "dd MMMM yyyy" + static let publish = "yyyy-MM-dd HH:mm" + static let torrent = "yyyy-MM-dd HH:mm" + static let comment = "dd MMMM yyyy, HH:mm" + static let github = "yyyy-MM-dd'T'HH:mm:ss'Z'" + } + struct FilePath { + static let logs = "logs" + static let ehpandaLog = "EhPanda.log" + } + struct ParsingMark { + static let hexStart = "hexStart<" + static let hexEnd = ">hexEnd" + } + struct URL { + static var host: Foundation.URL { AppUtil.galleryHost == .exhentai ? exhentai : ehentai } + static let ehentai: Foundation.URL = .init(string: "https://e-hentai.org/").forceUnwrapped + static let exhentai: Foundation.URL = .init(string: "https://exhentai.org/").forceUnwrapped + + static let forum: Foundation.URL = .init(string: "https://forums.e-hentai.org/index.php").forceUnwrapped + static let login = forum.appending(queryItems: [.act: .loginAct, .code: .zeroOne]) + static let webLogin = forum.appending(queryItems: [.act: .loginAct]) + + static let api = host.appendingPathComponent("api.php") + static let myTags = host.appendingPathComponent("mytags") + static let news = ehentai.appendingPathComponent("news.php") + static let uConfig = host.appendingPathComponent("uconfig.php") + static let galleryPopups = host.appendingPathComponent("gallerypopups.php") + static let galleryTorrents = host.appendingPathComponent("gallerytorrents.php") + + static let popular = host.appendingPathComponent("popular") + static let watched = host.appendingPathComponent("watched") + static let toplist = ehentai.appendingPathComponent("toplist.php") + static let favorites = host.appendingPathComponent("favorites.php") + + // GitHub + static let github: Foundation.URL = .init(string: "https://github.com/").forceUnwrapped + static let githubAPI: Foundation.URL = .init(string: "https://api.github.com/repos/").forceUnwrapped + + // swiftlint:disable nesting identifier_name + enum Component { + enum Key: String { + // Functional Pages + case token = "t" + case gid = "gid" + case letterP = "p" + case page = "page" + case from = "from" + case favcat = "favcat" + case topcat = "tl" + case showUser = "showuser" + case fSearch = "f_search" + + case code = "CODE" + case act = "act" + case showComments = "hc" + case inlineSet = "inline_set" + + // Search favorites + case sn = "sn" + case st = "st" + case sf = "sf" + + // Filter + case fCats = "f_cats" + case advSearch = "advsearch" + case fSname = "f_sname" + case fStags = "f_stags" + case fSdesc = "f_sdesc" + case fStorr = "f_storr" + case fSto = "f_sto" + case fSdt1 = "f_sdt1" + case fSdt2 = "f_sdt2" + case fSh = "f_sh" + case fSr = "f_sr" + case fSrdd = "f_srdd" + case fSp = "f_sp" + case fSpf = "f_spf" + case fSpt = "f_spt" + case fSfl = "f_sfl" + case fSfu = "f_sfu" + case fSft = "f_sft" + + // Custom + case ehpandaWidth = "ehpandaWidth" + case ehpandaHeight = "ehpandaHeight" + case ehpandaOffset = "ehpandaOffset" + } + enum Value: String { + case one = "1" + case all = "all" + case zeroOne = "01" + case filterOn = "on" + case loginAct = "Login" + case addFavAct = "addfav" + case sortOrderByUpdateTime = "fs_p" + case sortOrderByFavoritedTime = "fs_f" + } + } + // swiftlint:enable nesting identifier_name + } +} diff --git a/EhPanda/App/Tools/EnvironmentKeys.swift b/EhPanda/App/Tools/EnvironmentKeys.swift new file mode 100644 index 00000000..174ab0d1 --- /dev/null +++ b/EhPanda/App/Tools/EnvironmentKeys.swift @@ -0,0 +1,19 @@ +// +// EnvironmentKeys.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/21. +// + +import SwiftUI + +struct InSheetKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var inSheet: Bool { + get { self[InSheetKey.self] } + set { self[InSheetKey.self] = newValue } + } +} diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift new file mode 100644 index 00000000..7cba814a --- /dev/null +++ b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift @@ -0,0 +1,81 @@ +// +// AlertKit_Extension.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import SwiftUI +import AlertKit + +extension View { + func jumpPageAlert( + index: Binding, isPresented: Binding, isFocused: Binding, + pageNumber: PageNumber, jumpAction: @escaping () -> Void + ) -> some View { + JumpPageAlert( + content: self, index: index, isPresented: isPresented, + isFocused: isFocused, pageNumber: pageNumber, jumpAction: jumpAction + ) + } +} + +private struct JumpPageAlert: View { + @Environment(\.colorScheme) private var colorScheme + + private let content: Content + @Binding private var index: String + @Binding private var isPresented: Bool + @Binding private var isFocused: Bool + private let pageNumber: PageNumber + private let jumpAction: () -> Void + + @FocusState private var focused + @StateObject private var manager = CustomAlertManager() + + init( + content: Content, + index: Binding, + isPresented: Binding, + isFocused: Binding, + pageNumber: PageNumber, + jumpAction: @escaping () -> Void + ) { + self.content = content + _index = index + _isPresented = isPresented + _isFocused = isFocused + self.pageNumber = pageNumber + self.jumpAction = jumpAction + } + + private var widthFactor: Double { + Defaults.FrameSize.alertWidthFactor + } + private var backgroundOpacity: Double { + colorScheme == .light ? 0.2 : 0.5 + } + + var body: some View { + content.customAlert( + manager: manager, + widthFactor: widthFactor, + backgroundOpacity: backgroundOpacity, + content: { + PageJumpView( + inputText: $index, + isFocused: $focused, + pageNumber: pageNumber + ) + }, + buttons: [ + .regular( + content: { Text(R.string.localizable.jumpPageViewButtonConfirm()) }, + action: jumpAction + ) + ] + ) + .synchronize($isFocused, $focused) + .synchronize($isPresented, $manager.isPresented) + } +} diff --git a/EhPanda/App/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift similarity index 57% rename from EhPanda/App/Extensions.swift rename to EhPanda/App/Tools/Extensions/Extensions.swift index 4bf925bf..12ab0a0d 100644 --- a/EhPanda/App/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -6,24 +6,8 @@ // import SwiftUI - -// MARK: UINavigationController -extension UINavigationController: UIGestureRecognizerDelegate { - // Enables the swipe-back gesture in fullscreen - override open func viewDidLoad() { - super.viewDidLoad() - interactivePopGestureRecognizer?.delegate = self - } - // Prevents above codes from blocking the slide menu - public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - viewControllers.count > 1 - } - // Gives the swipe-back gesture a higher priority - public func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { gestureRecognizer.isKind(of: UIScreenEdgePanGestureRecognizer.self) } -} +import Foundation +import OrderedCollections // MARK: Encodable extension Encodable { @@ -32,6 +16,13 @@ extension Encodable { } } +// MARK: UIApplication +extension UIApplication { + func endEditing() { + sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} + // MARK: Data extension Data { func toObject() -> O? { @@ -68,17 +59,60 @@ extension Float { } } +// MARK: URL +extension URL { + static let mock = Defaults.URL.ehentai + + func appending(queryItems: [URLQueryItem]) -> URL { + var components: URLComponents = .init( + url: self, resolvingAgainstBaseURL: false + ) + .forceUnwrapped + if components.queryItems == nil { + components.queryItems = [] + } + components.queryItems?.append(contentsOf: queryItems) + return components.url.forceUnwrapped + } + func appending(queryItems: OrderedDictionary) -> URL { + appending(queryItems: queryItems.map(URLQueryItem.init)) + } + func appending(queryItems: OrderedDictionary) -> URL { + appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1.rawValue) })) + } + func appending(queryItems: OrderedDictionary) -> URL { + appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1) })) + } + mutating func append(queryItems: [URLQueryItem]) { + self = appending(queryItems: queryItems) + } + mutating func append(queryItems: OrderedDictionary) { + self = appending(queryItems: queryItems) + } + mutating func append(queryItems: OrderedDictionary) { + self = appending(queryItems: queryItems) + } + mutating func append(queryItems: OrderedDictionary) { + self = appending(queryItems: queryItems) + } +} + // MARK: String extension String { - var hasLocalizedString: Bool { - localized != self + var notEmpty: Bool { + !isEmpty } - - var localized: String { - String(localized: String.LocalizationValue(self)) + var isInteger: Bool { + Int(self) != nil + } + var isValidGID: Bool { + notEmpty && isInteger + } + var localizedKey: LocalizedStringKey { + .init(self) } - func urlEncoded() -> String { + var urlEncoded: String { addingPercentEncoding( withAllowedCharacters: .urlQueryAllowed ) ?? "" @@ -88,15 +122,35 @@ extension String { prefix(1).capitalized + dropFirst() } + var isValidURL: Bool { + if let detector = try? NSDataDetector( + types: NSTextCheckingResult.CheckingType.link.rawValue + ) { + if let match = detector.firstMatch(in: self, options: [], + range: NSRange(location: 0, length: utf16.count) + ) { + return match.range.length == utf16.count + } else { return false } + } else { return false } + } + + var barcesAndSpacesRemoved: String { + replacingOccurrences(from: "(", to: ")", with: "") + .replacingOccurrences(from: "[", to: "]", with: "") + .replacingOccurrences(from: "{", to: "}", with: "") + .replacingOccurrences(from: "【", to: "】", with: "") + .replacingOccurrences(from: "「", to: "」", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + func replacingOccurrences( - from subString1: String, - to subString2: String, - with replacement: String + from subString1: String, to subString2: String, with replacement: String ) -> String { var result = self while let rangeA = result.range(of: subString1), - let rangeB = result.range(of: subString2) + let rangeB = result.range(of: subString2), + rangeA.lowerBound < rangeB.upperBound { let unwanted = result[rangeA.lowerBound.. URL { - if isValidURL { - return URL(string: self).forceUnwrapped - } else { - Logger.error("Invalid URL, redirect to default host...") - return URL(string: Defaults.URL.ehentai).forceUnwrapped - } + func caseInsensitiveContains(_ other: String) -> Bool { + range(of: other, options: .caseInsensitive) != nil } - - var isValidURL: Bool { - if let detector = try? NSDataDetector( - types: NSTextCheckingResult.CheckingType.link.rawValue - ) { - if let match = detector.firstMatch(in: self, options: [], - range: NSRange(location: 0, length: utf16.count) - ) { - return match.range.length == utf16.count - } else { return false } - } else { return false } + func caseInsensitiveEqualsTo(_ other: String) -> Bool { + caseInsensitiveContains(other) && count == other.count } } @@ -211,8 +251,28 @@ extension Color { } } -extension NSNotification.Name { - var publisher: NotificationCenter.Publisher { - NotificationCenter.default.publisher(for: self) +// MARK: Array +extension Array { + func removeDuplicates(by predicate: (Element, Element) -> Bool) -> Self { + var result = [Element]() + for value in self { + if result.filter({ predicate($0, value) }).isEmpty { + result.append(value) + } + } + return result + } + func removeDuplicates(by keyPath: KeyPath) -> Self { + removeDuplicates(by: { $0[keyPath: keyPath] == $1[keyPath: keyPath] }) + } + func removeDuplicates() -> Self where Element: Equatable { + removeDuplicates(by: ==) + } +} + +// MARK: Dictionary +extension Dictionary { + var tuples: [(Key, Value)] { + map({ ($0.key, $0.value) }) } } diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift new file mode 100644 index 00000000..ce1780d5 --- /dev/null +++ b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift @@ -0,0 +1,56 @@ +// +// Reducer_Extension.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/02. +// + +import SwiftUI +import ComposableArchitecture + +// MARK: Logging +extension Reducer { + func logging() -> Self { + .init { state, action, environment in + Logger.info(action) + return run(&state, action, environment) + } + } +} + +// MARK: Haptic +extension Reducer { + static func recurse(_ reducer: @escaping (Reducer) -> Reducer) -> Reducer { + var `self`: Reducer! + self = Reducer { state, action, environment in + reducer(self).run(&state, action, environment) + } + return self + } + func onBecomeNonNil( + unwrapping enum: @escaping (State) -> Enum?, + case casePath: CasePath, + perform additionalEffects: @escaping (inout State, Action, Environment) + -> Effect + ) -> Self { + .init { state, action, environment in + let previousCase = Binding.constant(`enum`(state)).case(casePath).wrappedValue + let effects = run(&state, action, environment) + let currentCase = Binding.constant(`enum`(state)).case(casePath).wrappedValue + + return previousCase == nil && currentCase != nil + ? .merge(effects, additionalEffects(&state, action, environment)) + : effects + } + } + func haptics( + unwrapping enum: @escaping (State) -> Enum?, + case casePath: CasePath, + hapticClient: @escaping (Environment) -> HapticClient, + style: UIImpactFeedbackGenerator.FeedbackStyle = .light + ) -> Self { + onBecomeNonNil(unwrapping: `enum`, case: casePath) { + hapticClient($2).generateFeedback(style).fireAndForget() + } + } +} diff --git a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift new file mode 100644 index 00000000..fa3113aa --- /dev/null +++ b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift @@ -0,0 +1,112 @@ +// +// SwiftUINavigation_Extension.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/13. +// + +import SwiftUI +import TTProgressHUD +import SwiftUINavigation + +extension NavigationLink { + init( + _ title: S, + unwrapping value: Binding, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination + ) where Destination == WrappedDestination?, Label == Text { + self.init( + title, + destination: Binding(unwrapping: value).map(destination), + isActive: value.isPresent() + ) + } + init( + unwrapping enum: Binding, + case casePath: CasePath, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination + ) where Destination == WrappedDestination?, Label == Text { + self.init( + "", unwrapping: `enum`.case(casePath), + destination: destination + ) + } +} + +extension View { + @ViewBuilder func sheet( + unwrapping enum: Binding, + case casePath: CasePath, + onDismiss: (() -> Void)? = nil, + isEnabled: Bool, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View + where Content: View { + if isEnabled { + sheet(unwrapping: `enum`, case: casePath, onDismiss: onDismiss, content: content) + } else { + self + } + } + func confirmationDialog( + message: String, + unwrapping enum: Binding, + case casePath: CasePath, + @ViewBuilder actions: @escaping (Case) -> A + ) -> some View { + self.confirmationDialog( + title: { _ in Text("") }, + titleVisibility: .hidden, + unwrapping: `enum`.case(casePath), + actions: actions, + message: { _ in Text(message) } + ) + } + func confirmationDialog( + message: String, + unwrapping enum: Binding, + case casePath: CasePath, + matching case: Case, + @ViewBuilder actions: @escaping (Case) -> A + ) -> some View { + self.confirmationDialog( + title: { _ in Text("") }, + titleVisibility: .hidden, + unwrapping: { + let unwrapping = `enum`.case(casePath) + let isMatched = `case` == unwrapping.wrappedValue + return isMatched ? unwrapping : .constant(nil) + }(), + actions: actions, + message: { _ in Text(message) } + ) + } + + func progressHUD( + config: TTProgressHUDConfig, + unwrapping enum: Binding, + case casePath: CasePath + ) -> some View { + ZStack { + self + TTProgressHUD( + `enum`.case(casePath).isRemovedDuplicatesPresent(), + config: config + ) + } + } +} + +extension Binding { + func isRemovedDuplicatesPresent() -> Binding where Value == Wrapped? { + .init( + get: { wrappedValue != nil }, + set: { isPresent, transaction in + guard self.transaction(transaction).wrappedValue != nil else { return } + if !isPresent { + self.transaction(transaction).wrappedValue = nil + } + } + ) + } +} diff --git a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift new file mode 100644 index 00000000..4f14a267 --- /dev/null +++ b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift @@ -0,0 +1,29 @@ +// +// TTProgressHUD_Extension.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/15. +// + +import TTProgressHUD + +extension TTProgressHUDConfig { + static let error: Self = error(caption: nil) + static let loading: Self = loading(title: R.string.localizable.hudTitleLoading()) + static let communicating: Self = loading(title: R.string.localizable.hudTitleCommunicating()) + static let savedToPhotoLibrary: Self = success(caption: R.string.localizable.hudCaptionSavedToPhotoLibrary()) + static let copiedToClipboardSucceeded: Self = success(caption: R.string.localizable.hudCaptionCopiedToClipboard()) + + static func loading(title: String? = nil) -> Self { + .init(type: .loading, title: title) + } + static func error(caption: String? = nil) -> Self { + autoHide(type: .error, title: R.string.localizable.hudTitleError(), caption: caption) + } + static func success(caption: String? = nil) -> Self { + autoHide(type: .success, title: R.string.localizable.hudTitleSuccess(), caption: caption) + } + static func autoHide(type: TTProgressHUDType, title: String? = nil, caption: String? = nil) -> Self { + .init(type: type, title: title, caption: caption, shouldAutoHide: true, autoHideInterval: 1) + } +} diff --git a/EhPanda/App/ViewModifiers.swift b/EhPanda/App/Tools/Extensions/ViewModifiers.swift similarity index 68% rename from EhPanda/App/ViewModifiers.swift rename to EhPanda/App/Tools/Extensions/ViewModifiers.swift index 23a380d0..1d8387cb 100644 --- a/EhPanda/App/ViewModifiers.swift +++ b/EhPanda/App/Tools/Extensions/ViewModifiers.swift @@ -13,22 +13,47 @@ extension View { clipShape(RoundedCorner(radius: radius, corners: corners)) } - @ViewBuilder func withHorizontalSpacing(height: CGFloat? = nil) -> some View { - Color.clear.frame(width: 8, height: height) + @ViewBuilder func withHorizontalSpacing(width: CGFloat = 8, height: CGFloat? = nil) -> some View { + Color.clear.frame(width: width, height: height) self - Color.clear.frame(width: 8, height: height) + Color.clear.frame(width: width, height: height) } - func withArrow() -> some View { + func withArrow(isVisible: Bool = true) -> some View { HStack { self Spacer() - Image(systemName: "chevron.right") + Image(systemSymbol: .chevronRight) .foregroundColor(.secondary) .imageScale(.small) - .opacity(0.5) + .opacity(isVisible ? 0.5 : 0) } } + + func autoBlur(radius: Double) -> some View { + blur(radius: radius) + .allowsHitTesting(radius < 1) + .animation(.linear(duration: 0.1), value: radius) + } + + func synchronize(_ first: Binding, _ second: Binding) -> some View { + self + .onChange(of: first.wrappedValue) { newValue in + second.wrappedValue = newValue + } + .onChange(of: second.wrappedValue) { newValue in + first.wrappedValue = newValue + } + } + func synchronize(_ first: Binding, _ second: FocusState.Binding) -> some View { + self + .onChange(of: first.wrappedValue) { newValue in + second.wrappedValue = newValue + } + .onChange(of: second.wrappedValue) { newValue in + first.wrappedValue = newValue + } + } } struct PlainLinearProgressViewStyle: ProgressViewStyle { @@ -142,19 +167,12 @@ struct RoundedCorner: Shape { } struct PreviewResolver { - static func getPreviewConfigs( - originalURL: String - ) -> (String, ImageModifier) { - guard let (plainURL, size, offset) = - Parser.parsePreviewConfigs( - string: originalURL - ) else { - return (originalURL, RoundedOffsetModifier( - size: nil, offset: nil - )) + static func getPreviewConfigs(originalURL: URL?) -> (URL?, ImageModifier) { + guard let url = originalURL, + let (plainURL, size, offset) = Parser.parsePreviewConfigs(url: url) + else { + return (originalURL, RoundedOffsetModifier(size: nil, offset: nil)) } - return (plainURL, RoundedOffsetModifier( - size: size, offset: offset - )) + return (plainURL, RoundedOffsetModifier(size: size, offset: offset)) } } diff --git a/EhPanda/App/Tools/ImageSaver.swift b/EhPanda/App/Tools/ImageSaver.swift deleted file mode 100644 index 393d394e..00000000 --- a/EhPanda/App/Tools/ImageSaver.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// ImageSaver.swift -// ImageSaver -// -// Created by 荒木辰造 on R 3/08/31. -// - -import SwiftUI -import Kingfisher - -final class ImageSaver: NSObject, ObservableObject { - @Published var saveSucceeded: Bool? - - func retrieveImage(url: URL) async throws -> UIImage { - if let cachedImage = try? await retrieveCache(key: url.absoluteString) { - return cachedImage - } else { - do { - return try await downloadImage(url: url) - } catch { - throw error - } - } - } - private func retrieveCache(key: String) async throws -> UIImage { - try await withCheckedThrowingContinuation { continuation in - KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in - switch result { - case .success(let result): - if let image = result.image { - continuation.resume(returning: image) - } else { - continuation.resume(throwing: AppError.notFound) - } - case .failure(let error): - Logger.error(error) - continuation.resume(throwing: error) - } - } - } - } - private func downloadImage(url: URL) async throws -> UIImage { - try await withCheckedThrowingContinuation { continuation in - KingfisherManager.shared.downloader.downloadImage(with: url, options: nil) { result in - switch result { - case .success(let result): - continuation.resume(returning: result.image) - case .failure(let error): - Logger.error(error) - continuation.resume(throwing: error) - } - } - } - } - - func saveImage(_ image: UIImage) { - UIImageWriteToSavedPhotosAlbum(image, self, #selector(didFinishSavingImage), nil) - DispatchQueue.main.async { [weak self] in - self?.saveSucceeded = nil - } - } - @objc func didFinishSavingImage( - _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer - ) { - if let error = error { - Logger.error(error) - } - saveSucceeded = error == nil - } -} diff --git a/EhPanda/App/Tools/Parser.swift b/EhPanda/App/Tools/Parser.swift index f21eb1fa..cd73f223 100644 --- a/EhPanda/App/Tools/Parser.swift +++ b/EhPanda/App/Tools/Parser.swift @@ -7,21 +7,23 @@ import Kanna import UIKit +import OpenCC struct Parser { // MARK: List static func parseListItems(doc: HTMLDocument) throws -> [Gallery] { - func parseCoverURL(node: XMLElement?) throws -> String { + func parseCoverURL(node: XMLElement?) throws -> URL { guard let node = node?.at_xpath("//div [@class='glthumb']")?.at_css("img") else { throw AppError.parseFailed } - var coverURL = node["data-src"] - if coverURL == nil { coverURL = node["src"] } + var urlString = node["data-src"] + if urlString == nil { urlString = node["src"] } - guard let url = coverURL + guard let coverURLString = urlString, + let coverURL = URL(string: coverURLString) else { throw AppError.parseFailed } - return url + return coverURL } func parsePublishedTime(node: XMLElement?) throws -> String { @@ -113,19 +115,19 @@ struct Parser { let (tags, language) = try? parseTagsAndLang(node: gl3cNode), let publishedTime = try? parsePublishedTime(node: gl2cNode), let title = link.at_xpath("//div [@class='glink']")?.text, - let galleryURL = link.at_xpath("//td [@class='gl3c glname'] //a")?["href"], + let galleryURLString = link.at_xpath("//td [@class='gl3c glname'] //a")?["href"], + let galleryURL = URL(string: galleryURLString), galleryURL.pathComponents.count >= 4, let postedDate = try? parseDate(time: publishedTime, format: Defaults.DateFormat.publish), - let category = Category(rawValue: link.at_xpath("//td [@class='gl1c glcat'] //div")?.text ?? ""), - let url = URL(string: galleryURL), url.pathComponents.count >= 4 + let category = Category(rawValue: link.at_xpath("//td [@class='gl1c glcat'] //div")?.text ?? "") else { continue } galleryItems.append( Gallery( - gid: url.pathComponents[2], - token: url.pathComponents[3], + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], title: title, rating: rating, - tags: tags, + tagStrings: tags, category: category, language: language, uploader: uploader, @@ -138,16 +140,16 @@ struct Parser { } if galleryItems.isEmpty, let banInterval = parseBanInterval(doc: doc) { - throw AppError.ipBanned(interval: banInterval) + throw AppError.ipBanned(banInterval) } return galleryItems } // MARK: Detail - static func parseGalleryURL(doc: HTMLDocument) throws -> String { - guard let galleryURL = doc.at_xpath("//div [@class='sb']")? - .at_xpath("//a")?["href"] else { throw AppError.parseFailed } + 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) { @@ -172,13 +174,13 @@ struct Parser { throw AppError.parseFailed } - func parseCoverURL(node: XMLElement?) throws -> String { + func parseCoverURL(node: XMLElement?) throws -> URL { guard let coverHTML = node?.at_xpath("//div [@id='gd1']")?.innerHTML, - let rangeA = coverHTML.range(of: "url("), - let rangeB = coverHTML.range(of: ")") + let rangeA = coverHTML.range(of: "url("), let rangeB = coverHTML.range(of: ")"), + let url = URL(string: .init(coverHTML[rangeA.upperBound.. [GalleryTag] { @@ -210,10 +212,10 @@ struct Parser { return tags } - func parseArcAndTor(node: XMLElement?) throws -> (String?, Int) { + func parseArcAndTor(node: XMLElement?) throws -> (URL?, Int) { guard let node = node else { throw AppError.parseFailed } - var archiveURL: String? + var archiveURL: URL? for g2gspLink in node.xpath("//p [@class='g2 gsp']") { if archiveURL == nil { archiveURL = try? parseArchiveURL(node: g2gspLink) @@ -334,13 +336,13 @@ struct Parser { let gdfNode = gd3Node.at_xpath("//div [@id='gdf']"), let coverURL = try? parseCoverURL(node: link), let tags = try? parseTags(node: gd4Node), - let previews = try? parsePreviews(doc: doc), + 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 favoredCount = Int(infoPanel[7]), + 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), @@ -350,22 +352,21 @@ struct Parser { let postedDate = try? parseDate(time: infoPanel[0], format: Defaults.DateFormat.publish) else { continue } - let isFavored = gdfNode + 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, - isFavored: isFavored, + isFavorited: isFavorited, visibility: visibility, - rating: containsUserRating ? - textRating ?? 0.0 : imgRating, - userRating: containsUserRating - ? imgRating : 0.0, + rating: containsUserRating ? textRating ?? 0.0 : imgRating, + userRating: containsUserRating ? imgRating : 0.0, ratingCount: ratingCount, category: category, language: language, @@ -373,9 +374,8 @@ struct Parser { postedDate: postedDate, coverURL: coverURL, archiveURL: arcAndTor.0, - parentURL: infoPanel[1] == "None" - ? nil : infoPanel[1], - favoredCount: favoredCount, + parentURL: URL(string: parentURLString), + favoritedCount: favoritedCount, pageCount: pageCount, sizeCount: sizeCount, sizeType: infoPanel[5], @@ -383,7 +383,7 @@ struct Parser { ) tmpGalleryState = GalleryState( gid: gid, tags: tags, - previews: previews, + previewURLs: previewURLs, previewConfig: try? parsePreviewConfig(doc: doc), comments: parseComments(doc: doc) ) @@ -398,12 +398,12 @@ struct Parser { let rangeB = reason.range(of: ".Sorry about that.") { let owner = String(reason[rangeA.upperBound.. [Int: String] { - func parseNormalPreviews(node: XMLElement) -> [Int: String] { - var previews = [Int: String]() + static func parsePreviewURLs(doc: HTMLDocument) throws -> [Int: URL] { + func parseNormalPreviewURLs(node: XMLElement) -> [Int: URL] { + var previewURLs = [Int: URL]() for link in node.xpath("//div") where link.className == nil { guard let imgLink = link.at_xpath("//img"), @@ -434,29 +434,31 @@ struct Parser { let width = linkStyle[rangeA.upperBound.. [Int: String] { - var previews = [Int: String]() + func parseLargePreviewURLs(node: XMLElement) -> [Int: URL] { + var previewURLs = [Int: URL]() for link in node.xpath("//img") { guard let index = Int(link["alt"] ?? ""), - let url = link["src"], !url.contains("blank.gif") + let urlString = link["src"], !urlString.contains("blank.gif"), + let url = URL(string: urlString) else { continue } - previews[index] = url + previewURLs[index] = url } - return previews + return previewURLs } guard let gdtNode = doc.at_xpath("//div [@id='gdt']"), @@ -464,8 +466,8 @@ struct Parser { else { throw AppError.parseFailed } return previewMode == "gdtl" - ? parseLargePreviews(node: gdtNode) - : parseNormalPreviews(node: gdtNode) + ? parseLargePreviewURLs(node: gdtNode) + : parseNormalPreviewURLs(node: gdtNode) } // MARK: Comment @@ -540,9 +542,9 @@ struct Parser { return comments } - // MARK: Content - static func parseThumbnails(doc: HTMLDocument) throws -> [Int: String] { - var thumbnails = [Int: String]() + // MARK: ImageURL + static func parseThumbnailURLs(doc: HTMLDocument) throws -> [Int: URL] { + var thumbnailURLs = [Int: URL]() guard let gdtNode = doc.at_xpath("//div [@id='gdt']"), let previewMode = try? parsePreviewMode(doc: doc) @@ -550,36 +552,39 @@ struct Parser { for link in gdtNode.xpath("//div [@class='\(previewMode)']") { guard let aLink = link.at_xpath("//a"), - let thumbnail = aLink["href"], + let thumbnailURLString = aLink["href"], + let thumbnailURL = URL(string: thumbnailURLString), let index = Int(aLink.at_xpath("//img")?["alt"] ?? "") else { continue } - thumbnails[index] = thumbnail + thumbnailURLs[index] = thumbnailURL } - return thumbnails + return thumbnailURLs } - static func parseRenewedThumbnail(doc: HTMLDocument, stored: URL) throws -> URL { + static func parseRenewedThumbnailURL(doc: HTMLDocument, storedThumbnailURL: URL) throws -> URL { 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 } let reloadToken = String(text[rangeA.upperBound.. User { var displayName: String? - var avatarURL: String? + var avatarURL: URL? for ipbLink in doc.xpath("//table [@class='ipbtable']") { guard let profileName = ipbLink.at_xpath("//div [@id='profilename']")?.text @@ -643,8 +648,9 @@ struct Parser { displayName = profileName for imgLink in ipbLink.xpath("//img") { - guard let imgURL = imgLink["src"], - imgURL.contains("forums.e-hentai.org/uploads") + guard let imgURLString = imgLink["src"], + imgURLString.contains("forums.e-hentai.org/uploads"), + let imgURL = URL(string: imgURLString) else { continue } avatarURL = imgURL @@ -664,13 +670,13 @@ struct Parser { var hathArchives = [GalleryArchive.HathArchive]() for link in node.xpath("//td") { - var tmpResolution: ArchiveRes? + var tmpResolution: ArchiveResolution? var tmpFileSize: String? var tmpGPPrice: String? for pLink in link.xpath("//p") { if let pText = pLink.text { - if let res = ArchiveRes(rawValue: pText) { + if let res = ArchiveResolution(rawValue: pText) { tmpResolution = res } if pText.contains("N/A") { @@ -723,7 +729,7 @@ struct Parser { var tmpUploader: String? var tmpFileName: String? var tmpHash: String? - var tmpTorrentURL: String? + var tmpTorrentURL: URL? for trLink in link.xpath("//tr") { for tdLink in trLink.xpath("//td") { @@ -754,7 +760,7 @@ struct Parser { let range = aURL.lastPathComponent.range(of: ".torrent") { tmpHash = String(aURL.lastPathComponent[.. APIKey { - var tmpKey: 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"), @@ -1214,7 +1215,7 @@ extension Parser { } // MARK: Balance - static func parseCurrentFunds(doc: HTMLDocument) throws -> (String, String)? { + static func parseCurrentFunds(doc: HTMLDocument) throws -> (String, String) { var tmpGP: String? var tmpCredits: String? @@ -1253,8 +1254,7 @@ extension Parser { var respString = response.joined(separator: " ") - if let rangeA = - respString.range(of: "A ") ?? respString.range(of: "An "), + 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") @@ -1263,11 +1263,15 @@ extension Parser { .trimmingCharacters(in: .whitespacesAndNewlines) .firstLetterCapitalized - if ArchiveRes(rawValue: resp) != nil { + if ArchiveResolution(rawValue: resp) != nil { let clientName = String(respString[rangeC.upperBound.. " + clientName + if !clientName.isEmpty { + respString = resp + " -> " + clientName + } else { + respString = resp + } } } @@ -1275,15 +1279,13 @@ extension Parser { } // MARK: ArchiveURL - static func parseArchiveURL(node: XMLElement) throws -> String { - var archiveURL: String? + 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: "',") + aLink.text?.contains("Archive Download") == true, let onClick = aLink["onclick"], + let rangeA = onClick.range(of: "popUp('"), let rangeB = onClick.range(of: "',") { - archiveURL = String(onClick[rangeA.upperBound.. [Int: String] { - var favoriteNames = [Int: String]() + // MARK: FavoriteCategories + 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") { @@ -1304,12 +1306,12 @@ extension Parser { let type = FavoritesType(rawValue: name) else { continue } - favoriteNames[type.index] = value + favoriteCategories[type.index] = value } } - if !favoriteNames.isEmpty { - return favoriteNames + if !favoriteCategories.isEmpty { + return favoriteCategories } else { throw AppError.parseFailed } @@ -1326,7 +1328,7 @@ extension Parser { guard let options = options, options.count >= 1 else { throw AppError.parseFailed } - for link in options where AppUtil.verifyEhPandaProfileName(with: link.text) { + for link in options where EhSetting.verifyEhPandaProfileName(with: link.text) { profileNotFound = false profileValue = Int(link["value"] ?? "") } @@ -1398,8 +1400,10 @@ extension Parser { ) } - if let href = link["href"] { - if let imgSrc = link.at_xpath("//img")?["src"] { + if let href = link["href"], let url = URL(string: href) { + if let imgSrc = link.at_xpath("//img")?["src"], + let imgURL = URL(string: imgSrc) + { if let content = contents.last, content.type == .linkedImg { @@ -1409,16 +1413,16 @@ extension Parser { type: .doubleLinkedImg, link: content.link, imgURL: content.imgURL, - secondLink: href, - secondImgURL: imgSrc + secondLink: url, + secondImgURL: imgURL ) ) } else { contents.append( CommentContent( type: .linkedImg, - link: href, - imgURL: imgSrc + link: url, + imgURL: imgURL ) ) } @@ -1436,7 +1440,7 @@ extension Parser { .trimmingCharacters( in: .whitespacesAndNewlines ), - link: href + link: url ) ) } @@ -1444,11 +1448,11 @@ extension Parser { contents.append( CommentContent( type: .singleLink, - link: href + link: url ) ) } - } else if let src = link["src"] { + } else if let src = link["src"], let url = URL(string: src) { if let content = contents.last, content.type == .singleImg { @@ -1457,14 +1461,14 @@ extension Parser { CommentContent( type: .doubleImg, imgURL: content.imgURL, - secondImgURL: src + secondImgURL: url ) ) } else { contents.append( CommentContent( type: .singleImg, - imgURL: src + imgURL: url ) ) } @@ -1519,20 +1523,30 @@ extension Parser { } // MARK: parsePreviewConfigs - static func parsePreviewConfigs(string: String) -> (String, CGSize, CGSize)? { - guard let rangeA = string.range(of: Defaults.PreviewIdentifier.width), - let rangeB = string.range(of: Defaults.PreviewIdentifier.height), - let rangeC = string.range(of: Defaults.PreviewIdentifier.offset) + static func parsePreviewConfigs(url: URL) -> (URL, CGSize, CGSize)? { + guard var components = URLComponents( + url: url, resolvingAgainstBaseURL: false + ), + let queryItems = components.queryItems else { return nil } - let plainURL = String(string[.. [String: String] { + func parseCHSTranslations(dict: [String: Any]) -> [String: String] { + let categories = dict["data"] as? [[String: Any]] ?? [] + let translationsBeforeMapping = categories.compactMap { + $0["data"] as? [String: Any] + }.reduce([], +) + + var translations = [String: String]() + translationsBeforeMapping.forEach { translation in + let originalText = translation.key + let dict = translation.value as? [String: Any] + + if let translatedText = dict?["name"] as? String { + translations[originalText] = translatedText + } + } + return translations + } + func convertToCHT(dict: [String: String]) -> [String: String] { + func customConversion(dict: inout [String: String]) { + if dict["full color"] != nil { + dict["full color"] = "全彩" + } + } + guard let preferredLanguage = Locale.preferredLanguages.first else { return [:] } + + var translations = [String: String]() + + var options: ChineseConverter.Options = [.traditionalize] + if preferredLanguage.contains("HK") { + options = [.traditionalize, .hkStandard] + } else if preferredLanguage.contains("TW") { + options = [.traditionalize, .twStandard, .twIdiom] + } + + guard let converter = try? ChineseConverter(options: options) + else { return [:] } + + dict.forEach { key, value in + translations[key] = converter.convert(value) + } + customConversion(dict: &translations) + + return translations + } + func tryParseAnyTranslations(dict: [String: Any]) -> [String: String] { + dict as? [String: String] ?? [:] + } + + let chsTranslations = parseCHSTranslations(dict: dict) + switch language { + case .japanese: + return tryParseAnyTranslations(dict: dict) + case .simplifiedChinese: + return chsTranslations + case .traditionalChinese: + return convertToCHT(dict: chsTranslations) + default: + return chsTranslations.isEmpty ? tryParseAnyTranslations(dict: dict) : chsTranslations + } + } } diff --git a/EhPanda/App/Tools/Utilities/AppUtil.swift b/EhPanda/App/Tools/Utilities/AppUtil.swift new file mode 100644 index 00000000..66729c4f --- /dev/null +++ b/EhPanda/App/Tools/Utilities/AppUtil.swift @@ -0,0 +1,30 @@ +// +// AppUtil.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/02. +// + +import Foundation + +struct AppUtil { + static var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "null" + } + static var build: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "null" + } + + static var galleryHost: GalleryHost { + let rawValue: String? = UserDefaultsUtil.value(forKey: .galleryHost) + return GalleryHost(rawValue: rawValue ?? "") ?? .ehentai + } + + static func dispatchMainSync(execute work: () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.sync(execute: work) + } + } +} diff --git a/EhPanda/App/Tools/Utilities/CookiesUtil.swift b/EhPanda/App/Tools/Utilities/CookiesUtil.swift new file mode 100644 index 00000000..717bda3f --- /dev/null +++ b/EhPanda/App/Tools/Utilities/CookiesUtil.swift @@ -0,0 +1,36 @@ +// +// CookiesUtil.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/02. +// + +import Foundation + +// MARK: Cookies +struct CookiesUtil { + static var didLogin: Bool { + CookiesUtil.verify(for: Defaults.URL.ehentai, isEx: false) + || CookiesUtil.verify(for: Defaults.URL.exhentai, isEx: true) + } + + static func verify(for url: URL, isEx: Bool) -> Bool { + guard let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty else { return false } + + var igneous, memberID, passHash: String? + cookies.forEach { cookie in + guard let expiresDate = cookie.expiresDate, expiresDate > .now, !cookie.value.isEmpty else { return } + if cookie.name == Defaults.Cookie.igneous && cookie.value != Defaults.Cookie.mystery { + igneous = cookie.value + } + if cookie.name == Defaults.Cookie.ipbMemberId { + memberID = cookie.value + } + if cookie.name == Defaults.Cookie.ipbPassHash { + passHash = cookie.value + } + } + + return (!isEx || igneous != nil) && memberID != nil && passHash != nil + } +} diff --git a/EhPanda/App/Tools/Utilities/DeviceUtil.swift b/EhPanda/App/Tools/Utilities/DeviceUtil.swift new file mode 100644 index 00000000..c860e0d1 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/DeviceUtil.swift @@ -0,0 +1,80 @@ +// +// DeviceUtil.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/02. +// + +import SwiftUI +import Foundation + +struct DeviceUtil { + static var isPad: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } + static var isPhone: Bool { + UIDevice.current.userInterfaceIdiom == .phone + } + + static var isPadWidth: Bool { + windowW >= 744 + } + + static var isSEWidth: Bool { + windowW <= 320 + } + + static var keyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .filter({ $0.activationState == .foregroundActive }) + .compactMap({ $0 as? UIWindowScene }).last? + .windows.filter({ $0.isKeyWindow }).last + } + static var anyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }).last? + .windows.last + } + + static var isLandscape: Bool { + [.landscapeLeft, .landscapeRight] + .contains(keyWindow?.windowScene?.interfaceOrientation) + } + + static var isPortrait: Bool { + [.portrait, .portraitUpsideDown] + .contains(keyWindow?.windowScene?.interfaceOrientation) + } + + static var windowW: CGFloat { + min(absWindowW, absWindowH) + } + + static var windowH: CGFloat { + max(absWindowW, absWindowH) + } + + static var screenW: CGFloat { + min(absScreenW, absScreenH) + } + + static var screenH: CGFloat { + max(absScreenW, absScreenH) + } + + static var absWindowW: CGFloat { + keyWindow?.frame.size.width ?? absScreenW + } + + static var absWindowH: CGFloat { + keyWindow?.frame.size.height ?? absScreenH + } + + static var absScreenW: CGFloat { + UIScreen.main.bounds.size.width + } + + static var absScreenH: CGFloat { + UIScreen.main.bounds.size.height + } +} diff --git a/EhPanda/App/Tools/Utilities/FileUtil.swift b/EhPanda/App/Tools/Utilities/FileUtil.swift new file mode 100644 index 00000000..1a4b36c9 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/FileUtil.swift @@ -0,0 +1,24 @@ +// +// FileUtil.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/02. +// + +import Foundation + +struct FileUtil { + static var documentDirectory: URL? { + url(for: .documentDirectory) + } + static var cachesDirectory: URL? { + url(for: .cachesDirectory) + } + static var logsDirectoryURL: URL? { + documentDirectory?.appendingPathComponent(Defaults.FilePath.logs) + } + + static func url(for searchPathDirectory: FileManager.SearchPathDirectory) -> URL? { + try? FileManager.default.url(for: searchPathDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + } +} diff --git a/EhPanda/App/Tools/Utilities/HapticUtil.swift b/EhPanda/App/Tools/Utilities/HapticUtil.swift new file mode 100644 index 00000000..3cf825eb --- /dev/null +++ b/EhPanda/App/Tools/Utilities/HapticUtil.swift @@ -0,0 +1,45 @@ +// +// HapticUtil.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/02. +// + +import SwiftUI +import AudioToolbox + +struct HapticUtil { + static func generateFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle) { + guard !isLegacyTapticEngine else { + generateLegacyFeedback() + return + } + UIImpactFeedbackGenerator(style: style).impactOccurred() + } + + static func generateNotificationFeedback(style: UINotificationFeedbackGenerator.FeedbackType) { + guard !isLegacyTapticEngine else { + generateLegacyFeedback() + return + } + UINotificationFeedbackGenerator().notificationOccurred(style) + } + + private static func generateLegacyFeedback() { + AudioServicesPlaySystemSound(1519) + AudioServicesPlaySystemSound(1520) + AudioServicesPlaySystemSound(1521) + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + } + + private static let isLegacyTapticEngine: Bool = { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + return ["iPhone8,1", "iPhone8,2"].contains(identifier) + }() +} diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/EhPanda/App/Tools/Utilities/URLUtil.swift new file mode 100644 index 00000000..8feb3d29 --- /dev/null +++ b/EhPanda/App/Tools/Utilities/URLUtil.swift @@ -0,0 +1,192 @@ +// +// URLUtil.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/31. +// + +import Foundation +import OrderedCollections + +struct URLUtil { + // Fetch + static func searchList(keyword: String, filter: Filter, pageNum: Int? = nil) -> URL { + var queryItems: OrderedDictionary = [.fSearch: keyword] + if let pageNum = pageNum { + queryItems[.page] = String(pageNum) + } + return Defaults.URL.host.appending(queryItems: queryItems).applyingFilter(filter) + } + static func moreSearchList(keyword: String, filter: Filter, pageNum: Int, lastID: String) -> URL { + Defaults.URL.host.appending(queryItems: [ + .fSearch: keyword, .page: String(pageNum), .from: lastID + ]) + .applyingFilter(filter) + } + static func frontpageList(filter: Filter, pageNum: Int? = nil) -> URL { + var url = Defaults.URL.host + if let pageNum = pageNum { + url.append(queryItems: [.page: String(pageNum)]) + } + return url.applyingFilter(filter) + } + static func moreFrontpageList(filter: Filter, pageNum: Int, lastID: String) -> URL { + Defaults.URL.host.appending(queryItems: [.page: String(pageNum), .from: lastID]).applyingFilter(filter) + } + static func popularList(filter: Filter) -> URL { + Defaults.URL.popular.applyingFilter(filter) + } + static func watchedList(filter: Filter, pageNum: Int? = nil, keyword: String = "") -> URL { + var url = Defaults.URL.watched + if let pageNum = pageNum { + url.append(queryItems: [.page: String(pageNum)]) + } + if !keyword.isEmpty { + url.append(queryItems: [.fSearch: keyword]) + } + return url.applyingFilter(filter) + } + static func moreWatchedList(filter: Filter, pageNum: Int, lastID: String, keyword: String = "") -> URL { + var url = Defaults.URL.watched.appending(queryItems: [.page: String(pageNum), .from: lastID]) + if !keyword.isEmpty { + url.append(queryItems: [.fSearch: keyword]) + } + return url.applyingFilter(filter) + } + static func favoritesList( + favIndex: Int, pageNum: Int? = nil, keyword: String = "", + sortOrder: FavoritesSortOrder? = nil + ) -> URL { + var url = Defaults.URL.favorites + if favIndex != -1 { + url.append(queryItems: [.favcat: String(favIndex)]) + } else { + url.append(queryItems: [.favcat: .all]) + } + if let pageNum = pageNum { + url.append(queryItems: [.page: String(pageNum)]) + } + if !keyword.isEmpty { + url.append(queryItems: [.fSearch: keyword]) + url.append(queryItems: [.sn: .filterOn, .st: .filterOn, .sf: .filterOn]) + } + if let sortOrder = sortOrder { + url.append(queryItems: [ + .inlineSet: sortOrder == .favoritedTime + ? .sortOrderByFavoritedTime : .sortOrderByUpdateTime + ]) + } + return url + } + static func moreFavoritesList(favIndex: Int, pageNum: Int, lastID: String, keyword: String = "") -> URL { + var url = Defaults.URL.favorites.appending(queryItems: [.page: String(pageNum), .from: lastID]) + if favIndex != -1 { + url.append(queryItems: [.favcat: String(favIndex)]) + } else { + url.append(queryItems: [.favcat: .all]) + } + if !keyword.isEmpty { + url.append(queryItems: [.fSearch: keyword]) + url.append(queryItems: [.sn: .filterOn, .st: .filterOn, .sf: .filterOn]) + } + return url + } + static func toplistsList(catIndex: Int, pageNum: Int? = nil) -> URL { + var url = Defaults.URL.toplist.appending(queryItems: [.topcat: String(catIndex)]) + if let pageNum = pageNum { + url.append(queryItems: [.letterP: String(pageNum)]) + } + return url + } + static func moreToplistsList(catIndex: Int, pageNum: Int) -> URL { + Defaults.URL.toplist.appending(queryItems: [.topcat: String(catIndex), .letterP: String(pageNum)]) + } + static func galleryDetail(url: URL) -> URL { + url.appending(queryItems: [.showComments: .one]) + } + static func galleryTorrents(gid: String, token: String) -> URL { + Defaults.URL.galleryTorrents.appending(queryItems: [.gid: gid, .token: token]) + } + + // Account Associated Operations + static func addFavorite(gid: String, token: String) -> URL { + Defaults.URL.galleryPopups + .appending(queryItems: [.gid: gid, .token: token]) + .appending(queryItems: [.act: .addFavAct]) + } + static func userInfo(uid: String) -> URL { + Defaults.URL.forum.appending(queryItems: [.showUser: uid]) + } + + // Misc + static func detailPage(url: URL, pageNum: Int) -> URL { + url.appending(queryItems: [.letterP: String(pageNum)]) + } + static func normalPreviewURL(plainURL: URL, width: String, height: String, offset: String) -> URL { + plainURL.appending(queryItems: [.ehpandaWidth: width, .ehpandaHeight: height, .ehpandaOffset: offset]) + } + + // GitHub + static func githubAPI(repoName: String) -> URL { + Defaults.URL.githubAPI.appendingPathComponent("\(repoName)/releases/latest") + } + static func githubDownload(repoName: String, fileName: String) -> URL { + Defaults.URL.github.appendingPathComponent("\(repoName)/releases/latest/download/\(fileName)") + } +} + +// MARK: Combining (Filter) +private extension URL { + func applyingFilter(_ filter: Filter) -> URL { + var queryItems1 = OrderedDictionary() + var queryItems2 = OrderedDictionary() + + var categoryValue = 0 + categoryValue += filter.doujinshi ? Category.doujinshi.filterValue : 0 + categoryValue += filter.manga ? Category.manga.filterValue : 0 + categoryValue += filter.artistCG ? Category.artistCG.filterValue : 0 + categoryValue += filter.gameCG ? Category.gameCG.filterValue : 0 + categoryValue += filter.western ? Category.western.filterValue : 0 + categoryValue += filter.nonH ? Category.nonH.filterValue : 0 + categoryValue += filter.imageSet ? Category.imageSet.filterValue : 0 + categoryValue += filter.cosplay ? Category.cosplay.filterValue : 0 + categoryValue += filter.asianPorn ? Category.asianPorn.filterValue : 0 + categoryValue += filter.misc ? Category.misc.filterValue : 0 + + if ![0, 1023].contains(categoryValue) { + queryItems1[.fCats] = String(categoryValue) + } + + if !filter.advanced { return appending(queryItems: queryItems1).appending(queryItems: queryItems2) } + queryItems2[.advSearch] = .one + + if filter.galleryName { queryItems2[.fSname] = .filterOn } + if filter.galleryTags { queryItems2[.fStags] = .filterOn } + if filter.galleryDesc { queryItems2[.fSdesc] = .filterOn } + if filter.torrentFilenames { queryItems2[.fStorr] = .filterOn } + if filter.onlyWithTorrents { queryItems2[.fSto] = .filterOn } + if filter.lowPowerTags { queryItems2[.fSdt1] = .filterOn } + if filter.downvotedTags { queryItems2[.fSdt2] = .filterOn } + if filter.expungedGalleries { queryItems2[.fSh] = .filterOn } + + if filter.minRatingActivated, [2, 3, 4, 5].contains(filter.minRating) { + queryItems2[.fSr] = .filterOn + queryItems1[.fSrdd] = String(filter.minRating) + } + + if filter.pageRangeActivated, let minPages = Int(filter.pageLowerBound), + let maxPages = Int(filter.pageUpperBound), + minPages > 0 && maxPages > 0 && minPages <= maxPages + { + queryItems2[.fSp] = .filterOn + queryItems1[.fSpf] = String(minPages) + queryItems1[.fSpt] = String(maxPages) + } + + 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/Tools/Utilities/UserDefaultsUtil.swift b/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift new file mode 100644 index 00000000..a286acbc --- /dev/null +++ b/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift @@ -0,0 +1,19 @@ +// +// UserDefaultsUtil.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/02. +// + +import Foundation + +struct UserDefaultsUtil { + static func value(forKey key: AppUserDefaults) -> T? { + UserDefaults.standard.value(forKey: key.rawValue) as? T + } +} + +enum AppUserDefaults: String { + case galleryHost + case clipboardChangeCount +} diff --git a/EhPanda/App/Utilities.swift b/EhPanda/App/Utilities.swift deleted file mode 100644 index 60610c7f..00000000 --- a/EhPanda/App/Utilities.swift +++ /dev/null @@ -1,489 +0,0 @@ -// -// Utilities.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/11/22. -// - -import SwiftUI -import Combine -import Kingfisher -import AudioToolbox -import LocalAuthentication - -// MARK: Authorization -struct AuthorizationUtil { - static var isSameAccount: Bool { - if let ehentai = URL(string: Defaults.URL.ehentai), - let exhentai = URL(string: Defaults.URL.exhentai) - { - let ehUID = CookiesUtil.get(for: ehentai, key: Defaults.Cookie.ipbMemberId).rawValue - let exUID = CookiesUtil.get(for: exhentai, key: Defaults.Cookie.ipbMemberId).rawValue - if !ehUID.isEmpty && !exUID.isEmpty { return ehUID == exUID } else { return true } - } else { - return true - } - } - - static var didLogin: Bool { - CookiesUtil.verify(for: Defaults.URL.ehentai.safeURL(), isEx: false) - || CookiesUtil.verify(for: Defaults.URL.exhentai.safeURL(), isEx: true) - } - - static func localAuth( - reason: String, successAction: (() -> Void)? = nil, - failureAction: (() -> Void)? = nil, - passcodeNotFoundAction: (() -> Void)? = nil - ) { - let context = LAContext() - var error: NSError? - - if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason.localized) { success, _ in - DispatchQueue.main.async { - if success { successAction?() } else { failureAction?() } - } - } - } else { - passcodeNotFoundAction?() - } - } -} - -// MARK: App -struct AppUtil { - static var opacityTransition: AnyTransition { - AnyTransition.opacity.animation(.default) - } - static var version: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "(null)" - } - static var build: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "(null)" - } - - static var isUnitTesting: Bool { - ProcessInfo.processInfo.environment[ - "XCTestConfigurationFilePath" - ] != nil - } - - static var galleryHost: GalleryHost { - let rawValue: String? = UserDefaultsUtil.value(forKey: .galleryHost) - return GalleryHost(rawValue: rawValue ?? "") ?? .ehentai - } - - static func setGalleryHost(value: GalleryHost) { - UserDefaultsUtil.set(value: value.rawValue, forKey: .galleryHost) - } - - static func verifyEhPandaProfileName(with name: String?) -> Bool { - ["EhPanda", "EhPanda (Default)"].contains(name ?? "") - } - - static func configureKingfisher(bypassesSNIFiltering: Bool, handlesCookies: Bool = true) { - let config = KingfisherManager.shared.downloader.sessionConfiguration - if handlesCookies { config.httpCookieStorage = HTTPCookieStorage.shared } - if bypassesSNIFiltering { config.protocolClasses = [DFURLProtocol.self] } - KingfisherManager.shared.downloader.sessionConfiguration = config - } - - static func presentActivity(items: [Any]) { - let activityVC = UIActivityViewController( - activityItems: items, applicationActivities: nil - ) - if DeviceUtil.isPad { - activityVC.popoverPresentationController?.sourceView = DeviceUtil.keyWindow - activityVC.popoverPresentationController?.sourceRect = CGRect( - x: DeviceUtil.screenW, y: 0, width: 200, height: 200 - ) - } - activityVC.modalPresentationStyle = .overFullScreen - DeviceUtil.keyWindow?.rootViewController? - .present(activityVC, animated: true, completion: nil) - HapticUtil.generateFeedback(style: .light) - } - - static func dispatchMainSync(execute work: () -> Void) { - if Thread.isMainThread { - work() - } else { - DispatchQueue.main.sync(execute: work) - } - } -} - -// MARK: Device -struct DeviceUtil { - static var viewControllersCount: Int { - guard let navigationVC = keyWindow?.rootViewController?.children.first - as? UINavigationController else { return -1 } - return navigationVC.viewControllers.count - } - - static var isPad: Bool { - UIDevice.current.userInterfaceIdiom == .pad - } - - static var isPadWidth: Bool { - windowW >= 744 - } - - static var isSEWidth: Bool { - windowW <= 320 - } - - static var keyWindow: UIWindow? { - UIApplication.shared.connectedScenes - .filter({ $0.activationState == .foregroundActive }) - .compactMap({ $0 as? UIWindowScene }).last? - .windows.filter({ $0.isKeyWindow }).last - } - - static var isLandscape: Bool { - [.landscapeLeft, .landscapeRight] - .contains(keyWindow?.windowScene?.interfaceOrientation) - } - - static var isPortrait: Bool { - [.portrait, .portraitUpsideDown] - .contains(keyWindow?.windowScene?.interfaceOrientation) - } - - static var windowW: CGFloat { - min(absWindowW, absWindowH) - } - - static var windowH: CGFloat { - max(absWindowW, absWindowH) - } - - static var screenW: CGFloat { - min(absScreenW, absScreenH) - } - - static var screenH: CGFloat { - max(absScreenW, absScreenH) - } - - static var absWindowW: CGFloat { - keyWindow?.frame.size.width ?? absScreenW - } - - static var absWindowH: CGFloat { - keyWindow?.frame.size.height ?? absScreenH - } - - static var absScreenW: CGFloat { - UIScreen.main.bounds.size.width - } - - static var absScreenH: CGFloat { - UIScreen.main.bounds.size.height - } -} - -// MARK: Haptic -struct HapticUtil { - static func generateFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle) { - guard !isLegacyTapticEngine else { - generateLegacyFeedback() - return - } - UIImpactFeedbackGenerator(style: style).impactOccurred() - } - - static func generateNotificationFeedback(style: UINotificationFeedbackGenerator.FeedbackType) { - guard !isLegacyTapticEngine else { - generateLegacyFeedback() - return - } - UINotificationFeedbackGenerator().notificationOccurred(style) - } - - private static func generateLegacyFeedback() { - AudioServicesPlaySystemSound(1519) - AudioServicesPlaySystemSound(1520) - AudioServicesPlaySystemSound(1521) - AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) - } - - private static var isLegacyTapticEngine: Bool { - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let identifier = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - return ["iPhone8,1", "iPhone8,2"].contains(identifier) - } -} - -// MARK: Pasteboard -struct PasteboardUtil { - static var url: URL? { - if UIPasteboard.general.hasURLs { - return UIPasteboard.general.url - } else { - return nil - } - } - - static var changeCount: Int? { - UserDefaultsUtil.value(forKey: .pasteboardChangeCount) - } - - static func setChangeCount(value: Int) { - UserDefaultsUtil.set(value: value, forKey: .pasteboardChangeCount) - } - - static func clear() { - UIPasteboard.general.string = "" - } - - static func save(value: String) { - UIPasteboard.general.string = value - HapticUtil.generateNotificationFeedback(style: .success) - } -} - -// MARK: UserDefaults -struct UserDefaultsUtil { - static func value(forKey key: AppUserDefaults) -> T? { - UserDefaults.standard.value(forKey: key.rawValue) as? T - } - - static func set(value: Any, forKey key: AppUserDefaults) { - UserDefaults.standard.set(value, forKey: key.rawValue) - } -} - -enum AppUserDefaults: String { - case galleryHost - case pasteboardChangeCount -} - -// MARK: Notification -struct NotificationUtil { - static func post(_ notification: AppNotification) { - NotificationCenter.default.post(name: notification.name, object: nil) - } -} - -enum AppNotification: String { - case appWidthDidChange - case shouldShowSlideMenu - case shouldHideSlideMenu - case bypassesSNIFilteringDidChange - case readingViewShouldHideStatusBar -} - -extension AppNotification { - var name: NSNotification.Name { - .init(rawValue: rawValue) - } - var publisher: NotificationCenter.Publisher { - name.publisher - } -} - -// MARK: Cookies -struct CookiesUtil { - static func initializeCookie(from cookie: HTTPCookie, value: String) -> HTTPCookie { - var properties = cookie.properties - properties?[.value] = value - return HTTPCookie(properties: properties ?? [:]) ?? HTTPCookie() - } - - static func checkExistence(for url: URL, key: String) -> Bool { - if let cookies = HTTPCookieStorage.shared.cookies(for: url) { - var existence: HTTPCookie? - cookies.forEach { cookie in - guard cookie.name == key else { return } - existence = cookie - } - return existence != nil - } else { - return false - } - } - - static func set( - for url: URL, key: String, value: String, path: String = "/", - expiresTime: TimeInterval = TimeInterval(60 * 60 * 24 * 365) - ) { - let expiredDate = Date(timeIntervalSinceNow: expiresTime) - let properties: [HTTPCookiePropertyKey: Any] = [ - .path: path, .name: key, .value: value, - .originURL: url, .expires: expiredDate - ] - if let cookie = HTTPCookie(properties: properties) { - HTTPCookieStorage.shared.setCookie(cookie) - } - } - - static func setIgneous(for response: HTTPURLResponse) { - guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } - setString.components(separatedBy: ", ") - .flatMap { $0.components(separatedBy: "; ") }.forEach { value in - [Defaults.URL.ehentai, Defaults.URL.exhentai].forEach { url in - [Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash, Defaults.Cookie.igneous].forEach { key in - guard !(url == Defaults.URL.ehentai && key == Defaults.Cookie.igneous), - let range = value.range(of: "\(key)=") else { return } - set(for: url.safeURL(), key: key, value: String(value[range.upperBound...]) ) - } - } - } - } - static func removeYay() { - remove(for: Defaults.URL.exhentai.safeURL(), key: "yay") - } - - static func remove(for url: URL, key: String) { - if let cookies = HTTPCookieStorage.shared.cookies(for: url) { - cookies.forEach { cookie in - guard cookie.name == key else { return } - HTTPCookieStorage.shared.deleteCookie(cookie) - } - } - } - - static func clearAll() { - if let historyCookies = HTTPCookieStorage.shared.cookies { - historyCookies.forEach { - HTTPCookieStorage.shared.deleteCookie($0) - } - } - } - - static func edit(for url: URL, key: String, value: String) { - var newCookie: HTTPCookie? - if let cookies = HTTPCookieStorage.shared.cookies(for: url) { - cookies.forEach { cookie in - guard cookie.name == key else { return } - newCookie = initializeCookie(from: cookie, value: value) - remove(for: url, key: key) - } - } - guard let cookie = newCookie else { return } - HTTPCookieStorage.shared.setCookie(cookie) - } - - static func get(for url: URL, key: String) -> CookieValue { - var value = CookieValue(rawValue: "", localizedString: Defaults.Cookie.null.localized) - - 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 { - value = CookieValue(rawValue: "", localizedString: Defaults.Cookie.expired.localized) - return - } - - guard cookie.value != Defaults.Cookie.mystery else { - value = CookieValue(rawValue: cookie.value, localizedString: Defaults.Cookie.mystery.localized) - return - } - - value = CookieValue(rawValue: cookie.value, localizedString: "") - } - - return value - } - - static func verify(for url: URL, isEx: Bool) -> Bool { - guard let cookies = HTTPCookieStorage.shared.cookies(for: url), - !cookies.isEmpty else { return false } - - var igneous, memberID, passHash: String? - - cookies.forEach { cookie in - guard let expiresDate = cookie.expiresDate, expiresDate > .now, !cookie.value.isEmpty else { return } - - if cookie.name == Defaults.Cookie.igneous && cookie.value != Defaults.Cookie.mystery { - igneous = cookie.value - } - - if cookie.name == Defaults.Cookie.ipbMemberId { - memberID = cookie.value - } - - if cookie.name == Defaults.Cookie.ipbPassHash { - passHash = cookie.value - } - } - - if isEx { - return igneous != nil && memberID != nil && passHash != nil - } else { - return memberID != nil && passHash != nil - } - } -} - -// MARK: URL -struct URLUtil { - private static func checkIfHandleable(url: URL) -> Bool { - (url.absoluteString.contains(Defaults.URL.ehentai) || url.absoluteString.contains(Defaults.URL.exhentai)) - && url.pathComponents.count >= 4 && ["g", "s"].contains(url.pathComponents[1]) - && !url.pathComponents[2].isEmpty && !url.pathComponents[3].isEmpty - } - - static func parseGID(url: URL, isGalleryURL: Bool) -> String { - var gid = url.pathComponents[2] - let token = url.pathComponents[3] - if let range = token.range(of: "-"), isGalleryURL { - gid = String(token[.. Void - ) { - guard checkIfHandleable(url: url) else { - if handlesOutgoingURL { - UIApplication.shared.open(url, options: [:]) - } - completion(false, nil, nil, nil) - return - } - - let token = url.pathComponents[3] - if let range = token.range(of: "-") { - let pageIndex = Int(token[range.upperBound...]) - completion(true, url, pageIndex, nil) - return - } - - if let range = url.absoluteString.range(of: url.pathComponents[3] + "/") { - let commentField = String(url.absoluteString[range.upperBound...]) - if let range = commentField.range(of: "#c") { - let commentID = String(commentField[range.upperBound...]) - completion(false, url, nil, commentID) - return - } - } - - completion(false, url, nil, nil) - } -} - -// MARK: File -struct FileUtil { - static var documentDirectory: URL? { - try? FileManager.default.url( - for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true - ) - } - - static var logsDirectoryURL: URL? { - documentDirectory?.appendingPathComponent( - Defaults.FilePath.logs - ) - } -} diff --git a/EhPanda/App/de.lproj/InfoPlist.strings b/EhPanda/App/de.lproj/InfoPlist.strings index 86c841b7..b46c7bb9 100644 --- a/EhPanda/App/de.lproj/InfoPlist.strings +++ b/EhPanda/App/de.lproj/InfoPlist.strings @@ -7,4 +7,4 @@ */ "NSFaceIDUsageDescription" = "Diese Berechtigung ist notwendig um Face ID zum Entsperren der Anwendung verwenden zu können."; -//"NSPhotoLibraryAddUsageDescription" = ""; +"NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index da99ff89..36efe95c 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -6,469 +6,873 @@ */ +// MARK: BanInterval +"enum.ban.interval.description.and" = "and"; + +// MARK: ToplistsType +"enum.toplists.type.value.yesterday" = "Yesterday"; +"enum.toplists.type.value.pastMonth" = "Past month"; +"enum.toplists.type.value.pastYear" = "Past year"; +"enum.toplists.type.value.allTime" = "All time"; + // MARK: Response -"You must have a H@H client assigned to your account to use this feature." = "Du benötigst einen deinem Konto zugehörigen H@H Client um diese Funktion nutzen zu können"; -"Your H@H client appears to be offline. Turn it on, then try again." = "Dein H@H Client scheint offline zu sein. Sieh nach, ob er läuft und probier's nochmal"; -"The requested gallery cannot be downloaded with the selected resolution." = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; +"hath.download.response.hathClientNotFound" = "Du benötigst einen deinem Konto zugehörigen H@H Client um diese Funktion nutzen zu können"; +"hath.download.response.hathClientNotOnline" = "Dein H@H Client scheint offline zu sein. Sieh nach, ob er läuft und probier's nochmal"; +"hath.download.response.invalidResolution" = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; // MARK: HUD -"Success" = "Erfolg"; -"Error" = "Fehler"; -"Communicating..." = "Verbinde..."; -"Copied to clipboard" = "In Zwischenablage kopiert"; +"hud.title.error" = "Fehler"; +"hud.title.success" = "Erfolg"; +"hud.title.loading" = "Wird geladen..."; +"hud.title.communicating" = "Verbinde..."; +"hud.caption.copiedToClipboard" = "In Zwischenablage kopiert"; +"hud.caption.savedToPhotoLibrary" = "Saved to photo library"; + +// MARK: AutoLock +"local.authorization.reason" = "Die App hat sich selbst gesperrt, da der auto-lock Zeitraum abgelaufen ist."; + +// MARK: Common value +"common.value.stars" = "%@ Sterne"; +"common.value.pages" = "%@ pages"; +"common.value.times" = "%@ mal"; +"common.value.day" = "%@ day"; +"common.value.days" = "%@ days"; +"common.value.hour" = "%@ hour"; +"common.value.hours" = "%@ hours"; +"common.value.minute" = "Nach %@ Minute"; +"common.value.minutes" = "Nach %@ Minuten"; +"common.value.second" = "%@ second"; +"common.value.seconds" = "Nach %@ Sekunden"; +"common.value.records" = "%@ Einträge"; + +// MARK: TabItem +"tab.item.title.home" = "Home"; +"tab.item.title.favorites" = "Favoriten"; +"tab.item.title.search" = "Suche"; +"tab.item.title.setting" = "Einstellungen"; + +// MARK: ToolbarItem +"toolbar.item.button.filters" = "Filters"; +"toolbar.item.button.jumpPage" = "Jump page"; +"toolbar.item.button.quickSearch" = "Quick search"; + +// MARK: JumpPage +"jump.page.view.title.jumpPage" = "Jump page"; +"jump.page.view.button.confirm" = "Confirm"; -// MARK: LockView -"The App has been locked due to the auto-lock expiration." = "Die App hat sich selbst gesperrt, da der auto-lock Zeitraum abgelaufen ist"; +// MARK: AlertView +"loading.view.title.loading" = "Wird geladen..."; +"loading.view.title.preparingDatabase" = "Preparing the database..."; +"not.login.view.title.needLogin" = "You need to login to access this feature."; +"not.login.view.button.login" = "Login"; +"error.view.button.retry" = "Erneut versuchen"; +"error.view.button.dropDatabase" = "Drop the database"; +"error.view.title.tryLater" = "Please try again later."; +"error.view.title.network" = "A network error occurred."; +"error.view.title.parsing" = "A parsing error occurred."; +"error.view.title.unknown" = "An unknown error occurred."; +"error.view.title.notFound" = "There seems to be nothing here."; +"error.view.title.databaseCorrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; +"error.view.title.ipBanned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; +"error.view.title.copyrightClaim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; +"error.view.title.galleryUnavailable" = "This gallery has been removed or is unavailable."; + +// MARK: ConfirmationDialog +"confirmation.dialog.title.dropDatabase" = "You will lose all your data in this app.\nAre you sure to drop the database?"; +"confirmation.dialog.title.removeCustomTranslations" = "Are you sure to remove your custom translations?"; +"confirmation.dialog.title.logout" = "Bist du sicher das du dich ausloggen möchtest?"; +"confirmation.dialog.title.delete" = "Are you sure to delete this item?"; +"confirmation.dialog.title.clear" = "Bist du sicher das du das löschen möchtest?"; +"confirmation.dialog.title.reset" = "Bist du sicher?"; +"confirmation.dialog.button.dropDatabase" = "Drop the database"; +"confirmation.dialog.button.remove" = "Remove"; +"confirmation.dialog.button.logout" = "Ausloggen"; +"confirmation.dialog.button.delete" = "Delete"; +"confirmation.dialog.button.clear" = "Löschen"; +"confirmation.dialog.button.reset" = "Zurücksetzen"; + +// MARK: SubSection +"sub.section.button.showAll" = "Alle anzeigen"; -// MARK: Common -"null" = "null"; -"expired" = "Abgelaufen"; -"mystery" = "Abgelehnt"; +// MARK: NewDawnView +"new.dawn.view.title.first" = "Es ist der Beginn eines neuen Tages!"; +"new.dawn.view.title.second" = "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser."; +// Greeting +"struct.greeting.mark.start" = "Du erhälst "; +"struct.greeting.mark.separator" = ", "; +"struct.greeting.mark.and" = " und "; +"struct.greeting.mark.end" = "!"; -// MARK: User -"favoriteNameByDev" = "Favoriten"; -"all_appendedByDev" = "Alle"; +// MARK: HomeView +"home.view.title.home" = "Home"; +"home.view.section.title.frontpage" = "Frontpage"; +"home.view.section.title.toplists" = "Toplists"; +"home.view.section.title.other" = "Other"; +// HomeMiscGridType +"home.misc.grid.type.title.popular" = "Beliebt"; +"home.misc.grid.type.title.watched" = "Meine Tags"; +"home.misc.grid.type.title.history" = "Verlauf"; + +// MARK: FrontpageView +"frontpage.view.title.frontpage" = "Frontpage"; + +// MARK: ToplistsView +"toplists.view.title.toplists" = "Toplists"; + +// MARK: PopularView +"popular.view.title.popular" = "Beliebt"; + +// MARK: WatchedView +"watched.view.title.watched" = "Meine Tags"; + +// MARK: HistoryView +"history.view.title.history" = "Verlauf"; + +// MARK: FavoritesView +"favorites.view.title.favorites" = "Favoriten"; +// FavoriteCategory +"favorite.category.default" = "Favoriten %@"; +"favorite.category.all" = "Alle"; + +// MARK: SearchView +"search.view.title.search" = "Suche"; +"search.view.section.title.recentlySearched" = "Recently searched"; +"search.view.section.title.recentlySeen" = "Recently seen"; +"search.view.section.title.quickSearch" = "Quick search"; +// Searchable prompt +"searchable.prompt.filter" = "Filter"; -// MARK: AlertView -"Loading..." = "Wird geladen..."; -"Login" = "Einloggen"; -//"There seems to be nothing here." = ""; -"Retry" = "Erneut versuchen"; -//"A network error occurred." = ""; -//"A parsing error occurred." = ""; -//"An unknown error occurred." = ""; -//"Please try again later." = ""; -//"This gallery has been removed or is unavailable." = ""; -//"This gallery is unavailable due to a copyright claim by PLACEHOLDER. Sorry about that." = ""; -//"Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software." = ""; -//"The ban expires in PLACEHOLDER." = ""; -"BAN_INTERVAL_AND" = " and "; -"BAN_INTERVAL_DAYS" = " days"; -"BAN_INTERVAL_HOURS" = " hours"; -"BAN_INTERVAL_MINUTES" = " minutes"; -"BAN_INTERVAL_SECONDS" = " seconds"; -//"Jump page" = ""; -//"Confirm" = ""; +// MARK: QuickSearchView +"quick.search.view.title.quickSearch" = "Quick search"; +"quick.search.view.title.editWord" = "Edit word"; +"quick.search.view.title.newWord" = "New word"; +"quick.search.view.title.content" = "Content"; +"quick.search.view.title.name" = "Name"; +"quick.search.view.placeholder.optional" = "Optional"; +"quick.search.view.toolbar.item.button.confirm" = "Confirm"; -// MARK: HomeView -//"Clear history" = ""; +// MARK: SettingView +"setting.view.title.setting" = "Einstellungen"; +// SettingStateRoute +"enum.setting.state.route.value.account" = "Konto"; +"enum.setting.state.route.value.general" = "Allgemein"; +"enum.setting.state.route.value.appearance" = "Oberfläche"; +"enum.setting.state.route.value.reading" = "Am Lesen"; +"enum.setting.state.route.value.laboratory" = "Experimentelles"; +"enum.setting.state.route.value.ehPanda" = "Über EhPanda"; + +// MARK: AccountSettingView +"account.setting.view.title.account" = "Konto"; +"account.setting.view.title.showsNewDawnGreeting" = "Neuer-Tag-Meldung anzeigen"; +"account.setting.view.button.login" = "Einloggen"; +"account.setting.view.button.logout" = "Ausloggen"; +"account.setting.view.button.accountConfiguration" = "Kontoeinstellungen"; +"account.setting.view.button.tagsManagement" = "Meine Tags bearbeiten"; +"account.setting.view.button.copyCookies" = "Cookies kopieren"; +// CookieValue +"struct.cookie.value.localized.string.expired" = "Abgelaufen"; +"struct.cookie.value.localized.string.mystery" = "Abgelehnt"; +"struct.cookie.value.localized.string.none" = "None"; + +// MARK: LoginView +"login.view.title.login" = "Einloggen"; +"login.view.title.username" = "Username"; +"login.view.title.password" = "Password"; + +// MARK: GeneralSettingView +"general.setting.view.title.general" = "Allgemein"; +"general.setting.view.title.language" = "Sprache"; +"general.setting.view.title.autoLock" = "Auto-Lock"; +"general.setting.view.title.translatesTags" = "Translates tags"; +"general.setting.view.title.redirectsLinksToTheSelectedHost" = "Links zum ausgewählten Host umleiten"; +"general.setting.view.title.detectsLinksFromClipboard" = "Übernimmt automatisch Links aus der Zwischenablage"; +"general.setting.view.title.backgroundBlurRadius" = "Background blur radius"; +"general.setting.view.button.logs" = "Logs"; +"general.setting.view.button.importCustomTranslations" = "Import custom translations"; +"general.setting.view.button.removeCustomTranslations" = "Remove custom translations"; +"general.setting.view.button.clearImageCaches" = "Zwischengespeicherte Bilder (Cache) löschen"; +"general.setting.view.value.defaultLanguageDescription" = "N/A"; +"general.setting.view.section.title.tagsTranslation" = "Tags translation"; +"general.setting.view.section.title.navigation" = "Navigation"; +"general.setting.view.section.title.security" = "Sicherheit"; +"general.setting.view.section.title.caches" = "Caches"; +// AutoLockPolicy +"enum.auto.lock.policy.value.never" = "Nie"; +"enum.auto.lock.policy.value.instantly" = "Sofort"; + +// MARK: LogsView +"logs.view.title.logs" = "Logs"; +"logs.view.title.latest" = "Neueste"; + +// MARK: AppearanceSettingView +"appearance.setting.view.title.appearance" = "Oberfläche"; +"appearance.setting.view.title.theme" = "Theme"; +"appearance.setting.view.title.tintColor" = "Farbe"; +"appearance.setting.view.title.displayMode" = "Display mode"; +"appearance.setting.view.title.showsTagsInList" = "Tags als Liste anzeigen"; +"appearance.setting.view.title.maximumNumberOfTags" = "Maximale Anzahl an Tags"; +"appearance.setting.view.button.appIcon" = "App icon"; +"appearance.setting.view.menu.title.infite" = "Infite"; +"appearance.setting.view.section.title.list" = "List"; +// PerferredColorScheme +"enum.perferred.color.scheme.value.automatic" = "Automatisch"; +"enum.perferred.color.scheme.value.light" = "Hell"; +"enum.perferred.color.scheme.value.dark" = "Dunkel"; +// AppIconType +"enum.app.icon.type.value.default" = "Standard"; +"enum.app.icon.type.value.ukiyoe" = "Ukiyo-e"; +// ListDisplayMode +"enum.display.mode.value.detail" = "Detail"; +"enum.display.mode.value.thumbnail" = "Thumbnail"; + +// MARK: AppIconView +"app.icon.view.title.appIcon" = "App icon"; + +// MARK: ReadingSettingView +"reading.setting.view.title.reading" = "Am Lesen"; +"reading.setting.view.title.direction" = "Direction"; +"reading.setting.view.title.preloadLimit" = "Preload limit"; +"reading.setting.view.title.enablesLandscape" = "Enables landscape"; +"reading.setting.view.title.separatorHeight" = "Höhe der Teilung"; +"reading.setting.view.title.maximumScaleFactor" = "Maximaler Skalierungsfaktor"; +"reading.setting.view.title.doubleTapScaleFactor" = "Doppel-Tap Skalierungsfaktor"; +"reading.setting.view.section.title.appearance" = "Oberfläche"; +// ReadingDirection +"enum.reading.direction.value.vertical" = "Vertikal"; +"enum.reading.direction.value.rightToLeft" = "Von rechts nach links"; +"enum.reading.direction.value.leftToRight" = "Von links nach rechts"; + +// MARK: LaboratorySettingView +"laboratory.setting.view.title.laboratory" = "Experimentelles"; +"laboratory.setting.view.title.bypassesSNIFiltering" = "SNI Filter umgehen"; + +// MARK: EhPandaView +"ehpanda.view.title.ehPanda" = "EhPanda"; +"ehpanda.view.button.website" = "Website"; +"ehpanda.view.button.altStoreSource" = "AltStore Quelle"; +"ehpanda.view.description.version" = "Version"; +"ehpanda.view.section.title.specialThanks" = "Special thanks"; +"ehpanda.view.section.title.codeLevelContributors" = "Code-level contributors"; +"ehpanda.view.section.title.translationContributors" = "Translation contributors"; +"ehpanda.view.section.title.acknowledgements" = "OK"; // MARK: DetailView -"Archive" = "Archiv"; -"Torrents" = "Torrents"; -"Share" = "Teilen"; -"Read" = "Lesen"; -"DESC_SCROLL_ITEM_FAVORITED" = "Favorisiert"; -"Times" = "mal"; -"Language" = "Sprache"; -"%lld Ratings" = "%lld Bewertungen"; -"Page Count" = "Seitenzahl"; -"Pages" = "Seiten"; -"File Size" = "Dateigröße"; -"Give a Rating" = "Bewertung abgeben"; -"Similar Gallery" = "Ähnliche Galerien"; -"Preview" = "Vorschau"; -"Comment" = "Kommentar"; -"Show All" = "Alle anzeigen"; - -// MARK: ArchiveView -"N/A" = "./."; -"Free" = "Frei"; -"ARCHIVE_RESOLUTION_ORIGINAL" = "Original"; -"Download To Hath Client" = "Mit Hath Client herunterladen"; +"detail.view.button.read" = "Lesen"; +"detail.view.button.postComment" = "Kommentar abgeben"; +"detail.view.toolbar.item.button.archives" = "Archiv"; +"detail.view.toolbar.item.button.torrents" = "Torrents"; +"detail.view.toolbar.item.button.share" = "Teilen"; +"detail.view.scroll.section.title.favorited" = "Favorisiert"; +"detail.view.scroll.section.title.language" = "Sprache"; +"detail.view.scroll.section.title.ratings" = "%@ Bewertungen"; +"detail.view.scroll.section.title.pageCount" = "Seitenzahl"; +"detail.view.scroll.section.title.fileSize" = "Dateigröße"; +"detail.view.scroll.section.description.favorited" = "mal"; +"detail.view.scroll.section.description.pageCount" = "Seiten"; +"detail.view.action.section.button.giveARating" = "Bewertung abgeben"; +"detail.view.action.section.button.similarGallery" = "Ähnliche Galerien"; +"detail.view.section.title.previews" = "Vorschau"; +"detail.view.section.title.comments" = "Kommentar"; + +// MARK: ArchivesView +"archives.view.title.archives" = "Archiv"; +"archives.view.button.downloadToHathClient" = "Mit H@H Client herunterladen"; +// HathArchive +"struct.hath.archive.price.value.free" = "Frei"; +"struct.hath.archive.price.value.notAvailable" = "./."; +"struct.hath.archive.resolution.value.original" = "Original"; + +// MARK: TorrentsView +"torrents.view.title.torrents" = "Torrents"; // MARK: GalleryInfosView -//"Gallery infos" = ""; -//"Title" = ""; -//"Japanese title" = ""; -//"Gallery URL" = ""; -//"Cover URL" = ""; -//"Archive URL" = ""; -//"Torrent URL" = ""; -//"Parent URL" = ""; -//"Category" = ""; -//"Uploader" = ""; -//"Posted date" = ""; -//"Visible" = ""; -//"Page count" = ""; -//"File size" = ""; -//"Favorited times" = ""; -//"Favorited" = ""; -//"Rating count" = ""; -//"Average rating" = ""; -//"User rating" = ""; -//"Torrent count" = ""; -//"Yes" = ""; -//"No" = ""; -//"Expunged" = ""; - -// MARK: CommentView -"Post Comment" = "Kommentar abgeben"; -"Edit Comment" = "Kommentar bearbeiten"; -"Cancel" = "Abbrechen"; -"Post" = "Senden"; +"gallery.infos.view.title.galleryInfos" = "Gallery infos"; +"gallery.infos.view.title.ID" = "ID"; +"gallery.infos.view.title.token" = "Token"; +"gallery.infos.view.title.title" = "Title"; +"gallery.infos.view.title.japaneseTitle" = "Japanese title"; +"gallery.infos.view.title.galleryURL" = "Gallery URL"; +"gallery.infos.view.title.coverURL" = "Cover URL"; +"gallery.infos.view.title.archiveURL" = "Archive URL"; +"gallery.infos.view.title.torrentURL" = "Torrent URL"; +"gallery.infos.view.title.parentURL" = "Parent URL"; +"gallery.infos.view.title.category" = "Category"; +"gallery.infos.view.title.uploader" = "Uploader"; +"gallery.infos.view.title.postedDate" = "Posted date"; +"gallery.infos.view.title.visibility" = "Visibility"; +"gallery.infos.view.title.language" = "Language"; +"gallery.infos.view.title.pageCount" = "Page count"; +"gallery.infos.view.title.fileSize" = "File size"; +"gallery.infos.view.title.favoritedTimes" = "Favorited times"; +"gallery.infos.view.title.favorited" = "Favorited"; +"gallery.infos.view.title.ratingCount" = "Rating count"; +"gallery.infos.view.title.averageRating" = "Average rating"; +"gallery.infos.view.title.myRating" = "My rating"; +"gallery.infos.view.title.torrentCount" = "Torrent count"; +"gallery.infos.view.value.none" = "None"; +"gallery.infos.view.value.yes" = "Yes"; +"gallery.infos.view.value.no" = "No"; +// GalleryVisibility +"gallery.visibility.value.yes" = "Yes"; +"gallery.visibility.value.no" = "No (%@)"; +"gallery.visibility.value.no.reason.expunged" = "Expunged"; + +// MARK: CommentsView +"comments.view.title.comments" = "Kommentar"; + +// MARK: PostCommentView +"post.comment.view.title.postComment" = "Kommentar abgeben"; +"post.comment.view.title.editComment" = "Kommentar bearbeiten"; +"post.comment.view.button.cancel" = "Abbrechen"; +"post.comment.view.button.post" = "Senden"; + +// MARK: PreviewsView +"previews.view.title.previews" = "Vorschau"; // MARK: ReadingView -//"AutoPlay" = ""; -//"Reload" = ""; -//"Copy" = ""; -//"Save" = ""; -//"Save original" = ""; -//"Saved to photo library" = ""; - -// MARK: SettingView -"Setting" = "Einstellungen"; -"Account" = "Konto"; -"Gallery" = "Galerie"; -"Login" = "Einloggen"; -//"Username" = ""; -//"Password" = ""; -"Logout" = "Ausloggen"; -"Are you sure to logout?" = "Bist du sicher das du dich ausloggen möchtest?"; -"Account configuration" = "Kontoeinstellungen"; -"Manage tags subscription" = "Meine Tags bearbeiten"; -"Copy cookies" = "Cookies kopieren"; - -"General" = "Allgemein"; -"Navigation" = "Navigation"; -"Redirects links to the selected host" = "Links zum ausgewählten Host umleiten"; -"Detects links from the clipboard" = "Übernimmt automatisch Links aus der Zwischenablage"; -"Security" = "Sicherheit"; -"Auto-Lock" = "Auto-Lock"; -"App switcher blur" = "Appinhalt bei Appwechsel unkenntlich machen"; -"Cache" = "Cache"; -"Clear" = "Löschen"; -"Are you sure to clear?" = "Bist du sicher das du das löschen möchtest?"; -"Clear image caches" = "Zwischengespeicherte Bilder (Cache) löschen"; - -"Appearance" = "Oberfläche"; -"Global" = "Global"; -"Theme" = "Theme"; -"Tint Color" = "Farbe"; -"App Icon" = "App Icon"; -//"Translates tags" = ""; -"List" = "List"; -//"Display mode" = ""; -"LIST_DISPLAY_MODE_DETAIL" = "Details"; -"LIST_DISPLAY_MODE_THUMBNAIL" = "Miniaturbild"; -"Shows tags in list" = "Tags als Liste anzeigen"; -"Maximum number of tags" = "Maximale Anzahl an Tags"; -//"Infinity" = ""; - -"Reading" = "Am Lesen"; -//"Direction" = ""; -"READING_DIRECTION_VERTICAL" = "Vertikal"; -"Right-to-left" = "Von rechts nach links"; -"Left-to-right" = "Von links nach rechts"; -//"Preload limit" = ""; -//"%lld pages" = "%lld "; -//"Prefers landscape" = ""; -"%lld times" = "%lld mal"; -"Separator height" = "Höhe der Teilung"; -"Maximum scale factor" = "Maximaler Skalierungsfaktor"; -"Double tap scale factor" = "Doppel-Tap Skalierungsfaktor"; -//"Dual-page mode" = ""; -//"Except the cover" = ""; - -"Laboratory" = "Experimentelles"; -"Bypass SNI Filtering" = "SNI Filter umgehen"; - -"About EhPanda" = "Über EhPanda"; -"Version" = "Version"; -"Website" = "Website"; -"AltStore Source" = "AltStore Quelle"; -"Acknowledgement" = "OK"; +"reading.view.context.menu.button.reload" = "Reload"; +"reading.view.context.menu.button.copy" = "Copy"; +"reading.view.context.menu.button.save" = "Save"; +"reading.view.context.menu.button.saveOriginal" = "Save original"; +"reading.view.context.menu.button.share" = "Teilen"; +"reading.view.toolbar.item.title.autoPlay" = "Auto-Play"; +"reading.view.toolbar.item.title.dualPageMode" = "Dual-Page mode"; +"reading.view.toolbar.item.title.exceptTheCover" = "Except the cover"; +// AutoPlayPolicy +"enum.auto.play.policy.value.off" = "Off"; + +// MARK: FiltersView +"filters.view.title.filters" = "Filters"; +"filters.view.title.advancedSettings" = "Erweiterte Einstellungen"; +"filters.view.title.searchGalleryName" = "Galerienamen durchsuchen"; +"filters.view.title.searchGalleryTags" = "Galerietags durchsuchen"; +"filters.view.title.searchGalleryDescription" = "Galeriebeschreibung durchsuchen"; +"filters.view.title.searchTorrentFilenames" = "Torrents durchsuchen"; +"filters.view.title.onlyShowGalleriesWithTorrents" = "Nur Galerien mit Torrents zeigen"; +"filters.view.title.searchLowPowerTags" = "Low-Power Tags miteinbeziehen"; +"filters.view.title.searchDownvotedTags" = "Negativ bewertete Tags miteinbeziehen"; +"filters.view.title.showExpungedGalleries" = "Gelöschte Galerien zeigen"; +"filters.view.title.setMinimumRating" = "Minimaleste Bewertung festlegen"; +"filters.view.title.minimumRating" = "Minimale Bewertung"; +"filters.view.title.setPagesRange" = "Seitenzahl-Bereich festlegen"; +"filters.view.title.pagesRange" = "Seitenzahl-Bereich"; +"filters.view.title.disableLanguageFilter" = "Gefilterte Sprachen miteinbeziehen"; +"filters.view.title.disableUploaderFilter" = "Gefilterte Uploader miteinbeziehen"; +"filters.view.title.disableTagsFilter" = "Gefilterte Tags miteinbeziehen"; +"filters.view.button.resetFilters" = "Filter zurücksetzen"; +"filters.view.section.title.advanced" = "Erweitert"; +"filters.view.section.title.defaultFilter" = "Standardfilter"; +// FilterRange +"enum.filter.range.value.search" = "Suche"; +"enum.filter.range.value.global" = "Global"; +"enum.filter.range.value.watched" = "Meine Tags"; // MARK: EhSettingView -//"Profile Settings" = ""; -//"Selected profile" = ""; -//"Set as default" = ""; -//"Delete profile" = ""; -//"Are you sure to delete this profile?" = ""; -//"Delete" = ""; -//"Rename" = ""; -//"Create new" = ""; -// -//"Image Load Settings" = ""; -//"Recommended." = ""; -//"Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports." = ""; -//"Donator only. You will not be able to browse as many pages, enable only if having severe problems." = ""; -//"Load images through the Hath network" = ""; -//"Any client" = ""; -//"Default port clients only" = ""; -//"LOAD_THROUGH_HATH_NO" = ""; -//"You appear to be browsing the site from **PLACEHOLDER** or use a VPN or proxy in this country, which means the site will try to load images from Hath clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below." = ""; -//"Browsing country" = ""; -// -//"Image Size Settings" = ""; -//"Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000." = ""; -//"Image resolution" = ""; -//"Auto" = ""; -//"While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)" = ""; -//"Image size" = ""; -//"Horizontal" = ""; -//"Vertical" = ""; -// -//"Gallery Name Display" = ""; -//"Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?" = ""; -//"Gallery name" = ""; -//"Default Title" = ""; -//"Japanese Title (if available)" = ""; -// -//"Archiver Settings" = ""; -//"The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here." = ""; -//"Archiver behavior" = ""; -//"Manual Select, Manual Start (Default)" = ""; -//"Manual Select, Auto Start" = ""; -//"Auto Select Original, Manual Start" = ""; -//"Auto Select Original, Auto Start" = ""; -//"Auto Select Resample, Manual Start" = ""; -//"Auto Select Resample, Auto Start" = ""; -// -//"Front Page Settings" = ""; -//"Which display mode would you like to use on the front and search pages?" = ""; -//"Compact" = ""; -//"Thumbnail" = ""; -//"Extended" = ""; -//"Minimal" = ""; -//"Minimal+" = ""; -//"What categories would you like to show by default on the front page and in searches?" = ""; -// -//"Here you can choose and rename your favorite categories." = ""; -//"You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting." = ""; -//"Favorites sort order" = ""; -//"By last gallery update time" = ""; -//"By favorited time" = ""; -// -//"Ratings" = ""; -//"By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works." = ""; -//"Ratings color" = ""; -// -//"Tag Namespaces" = ""; -//"If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces." = ""; -// -//"Tag Filtering Threshold" = ""; -//"You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999." = ""; -// -//"Tag Watching Threshold" = ""; -//"Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999." = ""; -// -//"Excluded Languages" = ""; -//"If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query." = ""; -//"Original" = ""; -//"Translated" = ""; -//"Rewrite" = ""; -// -//"Excluded Uploaders" = ""; -//"If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query." = ""; -//"You are currently using **%lld / 1000** exclusion slots." = ""; -// -//"Search Result Count" = ""; -//"How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)" = ""; -//"Result count" = ""; -// -//"Thumbnail Settings" = ""; -//"How would you like the mouse-over thumbnails on the front page to load when using List Mode?" = ""; -//"Pages load faster, but there may be a slight delay before a thumb appears." = ""; -//"Pages take longer to load, but there is no delay for loading a thumb after the page has loaded." = ""; -//"Thumbnail load timing" = ""; -//"On mouse-over" = ""; -//"On page load" = ""; -//"You can set a default thumbnail configuration for all galleries you visit." = ""; -//"Size" = ""; -//"Large" = ""; -//"Rows" = ""; -// -//"Thumbnail Scaling" = ""; -//"Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%." = ""; -//"Scale factor" = ""; -// -//"Viewport Override" = ""; -//"Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400." = ""; -//"Virtual width" = ""; -// -//"Gallery Comments" = ""; -//"Comments sort order" = ""; -//"Oldest comments first" = ""; -//"Recent comments first" = ""; -//"By highest score" = ""; -//"Comment votes show timing" = ""; -//"On score hover or click" = ""; -//"Always" = ""; -// -//"Gallery Tags" = ""; -//"Tags sort order" = ""; -//"Alphabetical" = ""; -//"By tag power" = ""; -// -//"Gallery Page Numbering" = ""; -//"Show gallery page numbers" = ""; -// -//"Hath Local Network Host" = ""; -//"This setting can be used if you have a Hath client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work." = ""; -//"IP address:Port" = ""; -// -//"Original Images" = ""; -//"Use original images" = ""; -//"Multi-Page Viewer" = ""; -//"Use Multi-Page Viewer" = ""; -//"Display style" = ""; -//"Align left, scale if overwidth" = ""; -//"Align center, scale if overwidth" = ""; -//"Align center, always scale" = ""; -//"Show thumbnail pane" = ""; - -// MARK: LogsView -"Logs" = "Logs"; -"Latest" = "Neueste"; -"%lld records" = "%lld Einträge"; - -// MARK: FilterView -"Filters" = "Filter"; -"Basic" = "Normal"; -"Reset filters" = "Filter zurücksetzen"; -"Are you sure to reset?" = "Bist du sicher?"; -"Reset" = "Zurücksetzen"; -"Advanced settings" = "Erweiterte Einstellungen"; -"Advanced" = "Erweitert"; -"Search gallery name" = "Galerienamen durchsuchen"; -"Search gallery tags" = "Galerietags durchsuchen"; -"Search gallery description" = "Galeriebeschreibung durchsuchen"; -"Search torrent filenames" = "Torrents durchsuchen"; -"Only show galleries with torrents" = "Nur Galerien mit Torrents zeigen"; -"Search Low-Power tags" = "Low-Power Tags miteinbeziehen"; -"Search downvoted tags" = "Negativ bewertete Tags miteinbeziehen"; -"Show expunged galleries" = "Gelöschte Galerien zeigen"; -"Set minimum rating" = "Minimaleste Bewertung festlegen"; -"Minimum rating" = "Minimale Bewertung"; -"%lld stars" = "%lld Sterne"; -"Set pages range" = "Seitenzahl-Bereich festlegen"; -"Pages range" = "Seitenzahl-Bereich"; -"Default Filter" = "Standardfilter"; -"Disable language filter" = "Gefilterte Sprachen miteinbeziehen"; -"Disable uploader filter" = "Gefilterte Uploader miteinbeziehen"; -"Disable tags filter" = "Gefilterte Tags miteinbeziehen"; - -// MARK: NewDawnView -"Show new dawn greeting" = "Neuer-Tag-Meldung anzeigen"; -"It is the dawn of a new day!" = "Es ist der Beginn eines neuen Tages!"; -"Reflecting on your journey so far, you find that you are a little wiser." = "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser."; -"GAINCONTENT_START" = "Du erhälst "; -"GAINCONTENT_SEPARATOR" = ", "; -"GAINCONTENT_AND" = " und "; -"GAINCONTENT_END" = "!"; - -// MARK: QuickSearchView -//"Quick search" = ""; -//"Alias" = ""; - -// MARK: HomeListType -"Search" = "Suche"; -"Frontpage" = "Startseite"; -"Popular" = "Beliebt"; -"Watched" = "Meine Tags"; -"Favorites" = "Favoriten"; -//"Toplists" = ""; -"Downloaded" = "Heruntergeladen"; -"History" = "Verlauf"; - -// MARK: ToplistType -//"All time" = ""; -//"Past year" = ""; -//"Past month" = ""; -//"Yesterday" = ""; +"eh.setting.view.title.hostSetting" = "%@ setting"; +"eh.setting.view.section.title.profileSettings" = "Profile Settings"; +"eh.setting.view.title.selectedProfile" = "Selected profile"; +"eh.setting.view.button.setAsDefault" = "Set as default"; +"eh.setting.view.button.deleteProfile" = "Delete profile"; +"eh.setting.view.button.rename" = "Rename"; +"eh.setting.view.button.createNew" = "Create new"; +"eh.setting.view.toolbar.item.button.done" = "Done"; + +"eh.setting.view.section.title.imageLoadSettings" = "Image Load Settings"; +"eh.setting.view.title.loadImagesThroughTheHathNetwork" = "Load images through the Hath network"; +"eh.setting.view.title.browsingCountry" = "Browsing country"; +"eh.setting.view.description.browsingCountry" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; +// EhSetting.LoadThroughHathSetting +"enum.eh.setting.load.through.hath.setting.value.anyClient" = "Any client"; +"enum.eh.setting.load.through.hath.setting.value.defaultPortOnly" = "Default port clients only"; +"enum.eh.setting.load.through.hath.setting.value.modernNo" = "No [Modern/HTTPS]"; +"enum.eh.setting.load.through.hath.setting.value.legacyNo" = "No [Legacy/HTTP]"; +"enum.eh.setting.load.through.hath.setting.description.anyClient" = "Recommended."; +"enum.eh.setting.load.through.hath.setting.description.defaultPortOnly" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; +"enum.eh.setting.load.through.hath.setting.description.modernNo" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; +"enum.eh.setting.load.through.hath.setting.description.legacyNo" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; + +"eh.setting.view.section.title.imageSizeSettings" = "Image Size Settings"; +"eh.setting.view.title.imageResolution" = "Image resolution"; +"eh.setting.view.description.imageResolution" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; +"eh.setting.view.title.imageSize" = "Image size"; +"eh.setting.view.description.imageSize" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; +"eh.setting.view.title.horizontal" = "Horizontal"; +"eh.setting.view.title.vertical" = "Vertical"; +// EhSetting.ImageResolution +"enum.eh.setting.image.resolution.value.auto" = "Auto"; + +"eh.setting.view.section.title.galleryNameDisplay" = "Gallery Name Display"; +"eh.setting.view.title.galleryName" = "Gallery name"; +"eh.setting.view.description.galleryName" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; +// EhSetting.GalleryName +"enum.eh.setting.gallery.name.value.default" = "Default Title"; +"enum.eh.setting.gallery.name.value.japanese" = "Japanese Title (if available)"; + +"eh.setting.view.section.title.archiverSettings" = "Archiver Settings"; +"eh.setting.view.title.archiverBehavior" = "Archiver behavior"; +"eh.setting.view.description.archiverBehavior" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; +// EhSetting.ArchiverBehavior +"enum.eh.setting.archiver.behavior.value.manualSelectManualStart" = "Manual Select, Manual Start (Default)"; +"enum.eh.setting.archiver.behavior.value.manualSelectAutoStart" = "Manual Select, Auto Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalManualStart" = "Auto Select Original, Manual Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalAutoStart" = "Auto Select Original, Auto Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleManualStart" = "Auto Select Resample, Manual Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleAutoStart" = "Auto Select Resample, Auto Start"; + +"eh.setting.view.section.title.frontPageSettings" = "Front Page Settings"; +"eh.setting.view.title.displayMode" = "Display mode"; +"eh.setting.view.description.displayMode" = "Which display mode would you like to use on the front and search pages?"; +"eh.setting.view.description.galleryCategory" = "What categories would you like to show by default on the front page and in searches?"; +// EhSetting.DisplayMode +"enum.eh.setting.display.mode.value.compact" = "Compact"; +"enum.eh.setting.display.mode.value.thumbnail" = "Thumbnail"; +"enum.eh.setting.display.mode.value.extended" = "Extended"; +"enum.eh.setting.display.mode.value.minimal" = "Minimal"; +"enum.eh.setting.display.mode.value.minimalPlus" = "Minimal+"; + +"eh.setting.view.section.title.favorites" = "Favorites"; +"eh.setting.view.description.favoriteCategories" = "Here you can choose and rename your favorite categories."; +"eh.setting.view.title.favoritesSortOrder" = "Favorites sort order"; +"eh.setting.view.description.favoritesSortOrder" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; +// EhSetting.FavoritesSortOrder +"enum.eh.setting.favorites.sort.order.value.lastUpdateTime" = "By last gallery update time"; +"enum.eh.setting.favorites.sort.order.value.favoritedTime" = "By favorited time"; + +"eh.setting.view.section.title.ratings" = "Ratings"; +"eh.setting.view.title.ratingsColor" = "Ratings color"; +"eh.setting.view.promt.ratingsColor" = "RRGGB"; +"eh.setting.view.description.ratingsColor" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; + +"eh.setting.view.section.title.tagsNamespaces" = "Tag Namespaces"; +"eh.setting.view.description.tagsNamespaces" = "If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces."; + +"eh.setting.view.section.title.tagFilteringThreshold" = "Tag Filtering Threshold"; +"eh.setting.view.title.tagFilteringThreshold" = "Tag Filtering Threshold"; +"eh.setting.view.description.tagFilteringThreshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; + +"eh.setting.view.section.title.tagWatchingThreshold" = "Tag Watching Threshold"; +"eh.setting.view.title.tagWatchingThreshold" = "Tag Watching Threshold"; +"eh.setting.view.description.tagWatchingThreshold" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; + +"eh.setting.view.section.title.excludedLanguages" = "Excluded Languages"; +"eh.setting.view.description.excludedLanguages" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; +// EhSetting.ExcludedLanguagesCategory +"enum.eh.setting.excluded.languages.category.value.original" = "Original"; +"enum.eh.setting.excluded.languages.category.value.translated" = "Translated"; +"enum.eh.setting.excluded.languages.category.value.rewrite" = "Rewrite"; + +"eh.setting.view.section.title.excludedUploaders" = "Excluded Uploaders"; +"eh.setting.view.description.excludedUploaders" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; +"eh.setting.view.description.excludedUploadersCount" = "You are currently using **%@ / %@** exclusion slots."; + +"eh.setting.view.section.title.searchResultCount" = "Search Result Count"; +"eh.setting.view.title.resultCount" = "Result count"; +"eh.setting.view.description.resultCount" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; + +"eh.setting.view.section.title.thumbnailSettings" = "Thumbnail Settings"; +"eh.setting.view.title.thumbnailLoadTiming" = "Thumbnail load timing"; +"eh.setting.view.description.thumbnailLoadTiming" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; +"eh.setting.view.description.thumbnailConfiguration" = "You can set a default thumbnail configuration for all galleries you visit."; +"eh.setting.view.title.thumbnailSize" = "Size"; +"eh.setting.view.title.thumbnailRowCount" = "Rows"; +// EhSetting.ThumbnailLoadTiming +"enum.eh.setting.thumbnail.load.timing.value.onMouseOver" = "On mouse-over"; +"enum.eh.setting.thumbnail.load.timing.value.onPageLoad" = "On page load"; +"enum.eh.setting.thumbnail.load.timing.description.onMouseOver" = "Pages load faster, but there may be a slight delay before a thumb appears."; +"enum.eh.setting.thumbnail.load.timing.description.onPageLoad" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; +// EhSetting.ThumbnailSize +"enum.eh.setting.thumbnail.size.value.normal" = "Normal"; +"enum.eh.setting.thumbnail.size.value.large" = "Large"; + +"eh.setting.view.section.title.thumbnailScaling" = "Thumbnail Scaling"; +"eh.setting.view.title.scaleFactor" = "Scale factor"; +"eh.setting.view.description.scaleFactor" = "Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%."; + +"eh.setting.view.section.title.viewportOverride" = "Viewport Override"; +"eh.setting.view.title.virtualWidth" = "Virtual width"; +"eh.setting.view.description.virtualWidth" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400."; + +"eh.setting.view.section.title.galleryComments" = "Gallery Comments"; +"eh.setting.view.title.commentsSortOrder" = "Comments sort order"; +"eh.setting.view.title.commentsVotesShowTiming" = "Comment votes show timing"; +// EhSetting.CommentsSortOrder +"enum.eh.setting.comments.sort.order.value.oldest" = "Oldest comments first"; +"enum.eh.setting.comments.sort.order.value.recent" = "Recent comments first"; +"enum.eh.setting.comments.sort.order.value.highestScore" = "By highest score"; +// EhSetting.CommentVotesShowTiming +"enum.eh.setting.comments.votes.show.timing.value.onHoverOrClick" = "On score hover or click"; +"enum.eh.setting.comments.votes.show.timing.value.always" = "Always"; + +"eh.setting.view.section.title.galleryTags" = "Gallery Tags"; +"eh.setting.view.title.tagsSortOrder" = "Tags sort order"; +// EhSetting.TagsSortOrder +"enum.eh.setting.tags.sort.order.value.alphabetical" = "Alphabetical"; +"enum.eh.setting.tags.sort.order.value.tagPower" = "By tag power"; + +"eh.setting.view.section.title.galleryPageNumbering" = "Gallery Page Numbering"; +"eh.setting.view.title.showGalleryPageNumbers" = "Show gallery page numbers"; + +"eh.setting.view.section.title.hathLocalNetworkHost" = "Hath Local Network Host"; +"eh.setting.view.title.ipAddressPort" = "IP address:Port"; +"eh.setting.view.description.ipAddressPort" = "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work."; + +"eh.setting.view.section.title.originalImages" = "Original Images"; +"eh.setting.view.title.useOriginalImages" = "Use original images"; + +"eh.setting.view.section.title.multiPageViewer" = "Multi-Page Viewer"; +"eh.setting.view.title.useMultiPageViewer" = "Use Multi-Page Viewer"; +"eh.setting.view.title.displayStyle" = "Display style"; +"eh.setting.view.title.showThumbnailPane" = "Show thumbnail pane"; +// EhSetting.MultiplePageViewerStyle +"enum.eh.setting.multiple.page.viewer.style.value.alignLeftScaleIfOverWidth" = "Align left, scale if overwidth"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterScaleIfOverWidth" = "Align center, scale if overwidth"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterAlwaysScale" = "Align center, always scale"; // MARK: Category -"Doujinshi" = "Doujinshi"; -"Manga" = "Manga"; -"Artist CG" = "Artist CG"; -"Game CG" = "Game CG"; -"Western" = "Western"; -"Non-H" = "Non-H"; -"Image Set" = "Image Set"; -"Cosplay" = "Cosplay"; -"Asian Porn" = "Asian Porn"; -"Misc" = "Misc"; -//"Private" = ""; +"enum.category.value.doujinshi" = "Doujinshi"; +"enum.category.value.manga" = "Manga"; +"enum.category.value.artistCG" = "Artist CG"; +"enum.category.value.gameCG" = "Game CG"; +"enum.category.value.western" = "Western"; +"enum.category.value.nonH" = "Non-H"; +"enum.category.value.imageSet" = "Image Set"; +"enum.category.value.cosplay" = "Cosplay"; +"enum.category.value.asianPorn" = "Asian Porn"; +"enum.category.value.misc" = "Misc"; +"enum.category.value.private" = "Private"; // MARK: TagCategory -"Reclass" = "Reclass"; -"Language" = "Sprache"; -"Parody" = "Parodie"; -"Character" = "Charakter"; -"Group" = "Gruppe"; -"Artist" = "Künstler"; -"Male" = "Männlich"; -"Female" = "Weiblich"; -//"Mixed" = ""; -//"Cosplayer" = ""; -//"Other" = ""; -//"Temp" = ""; - -// MARK: IconType -"Normal" = "Normal"; -"Default" = "Standard"; -"Weird" = "Komisch"; - -// MARK: PreferredColorScheme -"Automatic" = "Automatisch"; -"Light" = "Hell"; -"Dark" = "Dunkel"; - -// MARK: AutoLockPolicy -"Never" = "Nie"; -"Instantly" = "Sofort"; -"%lld seconds" = "Nach %lld Sekunden"; -"%lld minute" = "Nach %lld Minute"; -"%lld minutes" = "Nach %lld Minuten"; +"enum.tag.category.value.reclass" = "Reclass"; +"enum.tag.category.value.language" = "Sprache"; +"enum.tag.category.value.parody" = "Parodie"; +"enum.tag.category.value.character" = "Charakter"; +"enum.tag.category.value.group" = "Gruppe"; +"enum.tag.category.value.artist" = "Künstler"; +"enum.tag.category.value.male" = "Männlich"; +"enum.tag.category.value.female" = "Weiblich"; +"enum.tag.category.value.mixed" = "Mixed"; +"enum.tag.category.value.cosplayer" = "Cosplayer"; +"enum.tag.category.value.other" = "Other"; +"enum.tag.category.value.temp" = "Temp"; // MARK: Language -"LANGUAGE_OTHER" = "Andere"; -"LANGUAGE_INVALID" = "N/A"; - -"Afrikaans" = "Afrikaan"; "Albanian" = "Albanisch"; "Arabic" = "Arabisch"; - -"Bengali" = "Bengali"; "Bosnian" = "Bosnisch"; "Bulgarian" = "Bulgarisch"; "Burmese" = "Birmanisch"; - -"Catalan" = "Katalanisch"; "Cebuano" = "Cebuano"; "Chinese" = "Chinesisch"; "Croatian" = "Kroatisch"; "Czech" = "Tschechisch"; - -"Danish" = "Dänisch"; "Dutch" = "Niederländisch"; - -"English" = "Englisch"; "Esperanto" = "Esperanto"; "Estonian" = "Estländisch"; - -"Finnish" = "Finnisch"; "French" = "Französisch"; - -"Georgian" = "Georgisch"; "German" = "Deutsch"; "Greek" = "Griechisch"; - -"Hebrew" = "Hebräisch"; "Hindi" = "Hindi"; "Hmong" = "Hmong"; "Hungarian" = "Ungarisch"; - -"Indonesian" = "Indonesisch"; // "Italian" = ""; - -"Japanese" = "Japanisch"; - -"Kazakh" = "Kazakhstanisch"; "Khmer" = "Khmer"; "Korean" = "Koreanisch"; "Kurdish" = "Kurdisch"; - -"Lao" = "Lao"; "Latin" = "Latein"; - -"Mongolian" = "Mongolisch"; - -"Ndebele" = "Ndebele"; "Nepali" = "Nepali"; "Norwegian" = "Norwegisch"; - -"Oromo" = "Oromo"; - -"Pashto" = "Pashto"; "Persian" = "Persisch"; "Polish" = "Polnisch"; "Portuguese" = "Portugiesisch"; "Punjabi" = "Punjabi"; - -"Romanian" = "Rumänisch"; "Russian" = "Russisch"; - -"Sango" = "Sango"; "Serbian" = "Serbisch"; "Shona" = "Shona"; "Slovak" = "Slovakisch"; "Slovenian" = "Slovenisch"; "Somali" = "Somali"; "Spanish" = "Spanisch"; "Swahili" = "Swahili"; "Swedish" = "Schwedisch"; - -"Tagalog" = "Tagalog"; "Thai" = "Thai"; "Tigrinya" = "Tigrinya"; "Turkish" = "Türkisch"; - -"Ukrainian" = "Ukrainisch"; "Urdu" = "Urdu"; - -"Vietnamese" = "Vietnamesisch"; - -"Zulu" = "Zulu"; - -// MARK: EhSettingCountry -//"Auto-Detect" = ""; "Afghanistan" = ""; "Aland Islands" = ""; "Albania" = ""; "Algeria" = ""; "American Samoa" = ""; "Andorra" = ""; "Angola" = ""; "Anguilla" = ""; "Antarctica" = ""; "Antigua and Barbuda" = ""; "Argentina" = ""; "Armenia" = ""; "Aruba" = ""; "Asia-Pacific Region" = ""; "Australia" = ""; "Austria" = ""; "Azerbaijan" = ""; "Bahamas" = ""; "Bahrain" = ""; "Bangladesh" = ""; "Barbados" = ""; "Belarus" = ""; "Belgium" = ""; "Belize" = ""; "Benin" = ""; "Bermuda" = ""; "Bhutan" = ""; "Bolivia" = ""; "Bonaire Saint Eustatius and Saba" = ""; "Bosnia and Herzegovina" = ""; "Botswana" = ""; "Bouvet Island" = ""; "Brazil" = ""; "British Indian Ocean Territory" = ""; "Brunei Darussalam" = ""; "Bulgaria" = ""; "Burkina Faso" = ""; "Burundi" = ""; "Cambodia" = ""; "Cameroon" = ""; "Canada" = ""; "Cape Verde" = ""; "Cayman Islands" = ""; "Central African Republic" = ""; "Chad" = ""; "Chile" = ""; "China" = ""; "Christmas Island" = ""; "Cocos Islands" = ""; "Colombia" = ""; "Comoros" = ""; "Congo" = ""; "The Democratic Republic of the Congo" = ""; "Cook Islands" = ""; "Costa Rica" = ""; "Cote D'Ivoire" = ""; "Croatia" = ""; "Cuba" = ""; "Curacao" = ""; "Cyprus" = ""; "Czech Republic" = ""; "Denmark" = ""; "Djibouti" = ""; "Dominica" = ""; "Dominican Republic" = ""; "Ecuador" = ""; "Egypt" = ""; "El Salvador" = ""; "Equatorial Guinea" = ""; "Eritrea" = ""; "Estonia" = ""; "Ethiopia" = ""; "Europe" = ""; "Falkland Islands" = ""; "Faroe Islands" = ""; "Fiji" = ""; "Finland" = ""; "France" = ""; "French Guiana" = ""; "French Polynesia" = ""; "French Southern Territories" = ""; "Gabon" = ""; "Gambia" = ""; "Georgia" = ""; "Germany" = ""; "Ghana" = ""; "Gibraltar" = ""; "Greece" = ""; "Greenland" = ""; "Grenada" = ""; "Guadeloupe" = ""; "Guam" = ""; "Guatemala" = ""; "Guernsey" = ""; "Guinea" = ""; "Guinea-Bissau" = ""; "Guyana" = ""; "Haiti" = ""; "Heard Island and McDonald Islands" = ""; "Vatican City State" = ""; "Honduras" = ""; "Hong Kong" = ""; "Hungary" = ""; "Iceland" = ""; "India" = ""; "Indonesia" = ""; "Iran" = ""; "Iraq" = ""; "Ireland" = ""; "Isle of Man" = ""; "Israel" = ""; "Italy" = ""; "Jamaica" = ""; "Japan" = ""; "Jersey" = ""; "Jordan" = ""; "Kazakhstan" = ""; "Kenya" = ""; "Kiribati" = ""; "Kuwait" = ""; "Kyrgyzstan" = ""; "Lao People's Democratic Republic" = ""; "Latvia" = ""; "Lebanon" = ""; "Lesotho" = ""; "Liberia" = ""; "Libya" = ""; "Liechtenstein" = ""; "Lithuania" = ""; "Luxembourg" = ""; "Macau" = ""; "Macedonia" = ""; "Madagascar" = ""; "Malawi" = ""; "Malaysia" = ""; "Maldives" = ""; "Mali" = ""; "Malta" = ""; "Marshall Islands" = ""; "Martinique" = ""; "Mauritania" = ""; "Mauritius" = ""; "Mayotte" = ""; "Mexico" = ""; "Micronesia" = ""; "Moldova" = ""; "Monaco" = ""; "Mongolia" = ""; "Montenegro" = ""; "Montserrat" = ""; "Morocco" = ""; "Mozambique" = ""; "Myanmar" = ""; "Namibia" = ""; "Nauru" = ""; "Nepal" = ""; "Netherlands" = ""; "New Caledonia" = ""; "New Zealand" = ""; "Nicaragua" = ""; "Niger" = ""; "Nigeria" = ""; "Niue" = ""; "Norfolk Island" = ""; "North Korea" = ""; "Northern Mariana Islands" = ""; "Norway" = ""; "Oman" = ""; "Pakistan" = ""; "Palau" = ""; "Palestinian Territory" = ""; "Panama" = ""; "Papua New Guinea" = ""; "Paraguay" = ""; "Peru" = ""; "Philippines" = ""; "Pitcairn Islands" = ""; "Poland" = ""; "Portugal" = ""; "Puerto Rico" = ""; "Qatar" = ""; "Reunion" = ""; "Romania" = ""; "Russian Federation" = ""; "Rwanda" = ""; "Saint Barthelemy" = ""; "Saint Helena" = ""; "Saint Kitts and Nevis" = ""; "Saint Lucia" = ""; "Saint Martin" = ""; "Saint Pierre and Miquelon" = ""; "Saint Vincent and the Grenadines" = ""; "Samoa" = ""; "San Marino" = ""; "Sao Tome and Principe" = ""; "Saudi Arabia" = ""; "Senegal" = ""; "Serbia" = ""; "Seychelles" = ""; "Sierra Leone" = ""; "Singapore" = ""; "Sint Maarten" = ""; "Slovakia" = ""; "Slovenia" = ""; "Solomon Islands" = ""; "Somalia" = ""; "South Africa" = ""; "South Georgia and the South Sandwich Islands" = ""; "South Korea" = ""; "South Sudan" = ""; "Spain" = ""; "Sri Lanka" = ""; "Sudan" = ""; "Suriname" = ""; "Svalbard and Jan Mayen" = ""; "Swaziland" = ""; "Sweden" = ""; "Switzerland" = ""; "Syrian Arab Republic" = ""; "Taiwan" = ""; "Tajikistan" = ""; "Tanzania" = ""; "Thailand" = ""; "Timor-Leste" = ""; "Togo" = ""; "Tokelau" = ""; "Tonga" = ""; "Trinidad and Tobago" = ""; "Tunisia" = ""; "Turkey" = ""; "Turkmenistan" = ""; "Turks and Caicos Islands" = ""; "Tuvalu" = ""; "Uganda" = ""; "Ukraine" = ""; "United Arab Emirates" = ""; "United Kingdom" = ""; "United States" = ""; "United States Minor Outlying Islands" = ""; "Uruguay" = ""; "Uzbekistan" = ""; "Vanuatu" = ""; "Venezuela" = ""; "Vietnam" = ""; "British Virgin Islands" = ""; "U.S. Virgin Islands" = ""; "Wallis and Futuna" = ""; "Western Sahara" = ""; "Yemen" = ""; "Zambia" = ""; "Zimbabwe" = ""; +"enum.language.value.invalid" = "./."; +"enum.language.value.other" = "Other"; +"enum.language.value.afrikaans" = "Afrikaan"; +"enum.language.value.albanian" = "Albanisch"; +"enum.language.value.arabic" = "Arabisch"; +"enum.language.value.bengali" = "Bengali"; +"enum.language.value.bosnian" = "Bosnisch"; +"enum.language.value.bulgarian" = "Bulgarisch"; +"enum.language.value.burmese" = "Birmanisch"; +"enum.language.value.catalan" = "Katalanisch"; +"enum.language.value.cebuano" = "Cebuano"; +"enum.language.value.chinese" = "Chinesisch"; +"enum.language.value.croatian" = "Kroatisch"; +"enum.language.value.czech" = "Tschechisch"; +"enum.language.value.danish" = "Dänisch"; +"enum.language.value.dutch" = "Niederländisch"; +"enum.language.value.english" = "Englisch"; +"enum.language.value.esperanto" = "Esperanto"; +"enum.language.value.estonian" = "Estländisch"; +"enum.language.value.finnish" = "Finnisch"; +"enum.language.value.french" = "Französisch"; +"enum.language.value.georgian" = "Georgisch"; +"enum.language.value.german" = "Deutsch"; +"enum.language.value.greek" = "Griechisch"; +"enum.language.value.hebrew" = "Hebräisch"; +"enum.language.value.hindi" = "Hindi"; +"enum.language.value.hmong" = "Hmong"; +"enum.language.value.hungarian" = "Ungarisch"; +"enum.language.value.indonesian" = "Indonesisch"; +"enum.language.value.italian" = "Italian"; +"enum.language.value.japanese" = "Japanisch"; +"enum.language.value.kazakh" = "Kazakhstanisch"; +"enum.language.value.khmer" = "Khmer"; +"enum.language.value.korean" = "Koreanisch"; +"enum.language.value.kurdish" = "Kurdisch"; +"enum.language.value.lao" = "Lao"; +"enum.language.value.latin" = "Latein"; +"enum.language.value.mongolian" = "Mongolisch"; +"enum.language.value.ndebele" = "Ndebele"; +"enum.language.value.nepali" = "Nepali"; +"enum.language.value.norwegian" = "Norwegisch"; +"enum.language.value.oromo" = "Oromo"; +"enum.language.value.pashto" = "Pashto"; +"enum.language.value.persian" = "Persisch"; +"enum.language.value.polish" = "Polnisch"; +"enum.language.value.portuguese" = "Portugiesisch"; +"enum.language.value.punjabi" = "Punjabi"; +"enum.language.value.romanian" = "Rumänisch"; +"enum.language.value.russian" = "Russisch"; +"enum.language.value.sango" = "Sango"; +"enum.language.value.serbian" = "Serbisch"; +"enum.language.value.shona" = "Shona"; +"enum.language.value.slovak" = "Slovakisch"; +"enum.language.value.slovenian" = "Slovenisch"; +"enum.language.value.somali" = "Somali"; +"enum.language.value.spanish" = "Spanisch"; +"enum.language.value.swahili" = "Swahili"; +"enum.language.value.swedish" = "Schwedisch"; +"enum.language.value.tagalog" = "Tagalog"; +"enum.language.value.thai" = "Thai"; +"enum.language.value.tigrinya" = "Tigrinya"; +"enum.language.value.turkish" = "Türkisch"; +"enum.language.value.ukrainian" = "Ukrainisch"; +"enum.language.value.urdu" = "Urdu"; +"enum.language.value.vietnamese" = "Vietnamesisch"; +"enum.language.value.zulu" = "Zulu"; + +// MARK: BrowsingCountry +"enum.browsing.country.name.autoDetect" = "Auto-Detect"; +"enum.browsing.country.name.afghanistan" = "Afghanistan"; +"enum.browsing.country.name.alandIslands" = "Aland Islands"; +"enum.browsing.country.name.albania" = "Albania"; +"enum.browsing.country.name.algeria" = "Algeria"; +"enum.browsing.country.name.americanSamoa" = "American Samoa"; +"enum.browsing.country.name.andorra" = "Andorra"; +"enum.browsing.country.name.angola" = "Angola"; +"enum.browsing.country.name.anguilla" = "Anguilla"; +"enum.browsing.country.name.antarctica" = "Antarctica"; +"enum.browsing.country.name.antiguaAndBarbuda" = "Antigua and Barbuda"; +"enum.browsing.country.name.argentina" = "Argentina"; +"enum.browsing.country.name.armenia" = "Armenia"; +"enum.browsing.country.name.aruba" = "Aruba"; +"enum.browsing.country.name.asiaPacificRegion" = "Asia-Pacific Region"; +"enum.browsing.country.name.australia" = "Australia"; +"enum.browsing.country.name.austria" = "Austria"; +"enum.browsing.country.name.azerbaijan" = "Azerbaijan"; +"enum.browsing.country.name.bahamas" = "Bahamas"; +"enum.browsing.country.name.bahrain" = "Bahrain"; +"enum.browsing.country.name.bangladesh" = "Bangladesh"; +"enum.browsing.country.name.barbados" = "Barbados"; +"enum.browsing.country.name.belarus" = "Belarus"; +"enum.browsing.country.name.belgium" = "Belgium"; +"enum.browsing.country.name.belize" = "Belize"; +"enum.browsing.country.name.benin" = "Benin"; +"enum.browsing.country.name.bermuda" = "Bermuda"; +"enum.browsing.country.name.bhutan" = "Bhutan"; +"enum.browsing.country.name.bolivia" = "Bolivia"; +"enum.browsing.country.name.bonaireSaintEustatiusAndSaba" = "Bonaire Saint Eustatius and Saba"; +"enum.browsing.country.name.bosniaAndHerzegovina" = "Bosnia and Herzegovina"; +"enum.browsing.country.name.botswana" = "Botswana"; +"enum.browsing.country.name.bouvetIsland" = "Bouvet Island"; +"enum.browsing.country.name.brazil" = "Brazil"; +"enum.browsing.country.name.britishIndianOceanTerritory" = "British Indian Ocean Territory"; +"enum.browsing.country.name.bruneiDarussalam" = "Brunei Darussalam"; +"enum.browsing.country.name.bulgaria" = "Bulgaria"; +"enum.browsing.country.name.burkinaFaso" = "Burkina Faso"; +"enum.browsing.country.name.burundi" = "Burundi"; +"enum.browsing.country.name.cambodia" = "Cambodia"; +"enum.browsing.country.name.cameroon" = "Cameroon"; +"enum.browsing.country.name.canada" = "Canada"; +"enum.browsing.country.name.capeVerde" = "Cape Verde"; +"enum.browsing.country.name.caymanIslands" = "Cayman Islands"; +"enum.browsing.country.name.centralAfricanRepublic" = "Central African Republic"; +"enum.browsing.country.name.chad" = "Chad"; +"enum.browsing.country.name.chile" = "Chile"; +"enum.browsing.country.name.china" = "China"; +"enum.browsing.country.name.christmasIsland" = "Christmas Island"; +"enum.browsing.country.name.cocosIslands" = "Cocos Islands"; +"enum.browsing.country.name.colombia" = "Colombia"; +"enum.browsing.country.name.comoros" = "Comoros"; +"enum.browsing.country.name.congo" = "Congo"; +"enum.browsing.country.name.theDemocraticRepublicOfTheCongo" = "The Democratic Republic of the Congo"; +"enum.browsing.country.name.cookIslands" = "Cook Islands"; +"enum.browsing.country.name.costaRica" = "Costa Rica"; +"enum.browsing.country.name.coteDIvoire" = "Cote D'Ivoire"; +"enum.browsing.country.name.croatia" = "Croatia"; +"enum.browsing.country.name.cuba" = "Cuba"; +"enum.browsing.country.name.curacao" = "Curacao"; +"enum.browsing.country.name.cyprus" = "Cyprus"; +"enum.browsing.country.name.czechRepublic" = "Czech Republic"; +"enum.browsing.country.name.denmark" = "Denmark"; +"enum.browsing.country.name.djibouti" = "Djibouti"; +"enum.browsing.country.name.dominica" = "Dominica"; +"enum.browsing.country.name.dominicanRepublic" = "Dominican Republic"; +"enum.browsing.country.name.ecuador" = "Ecuador"; +"enum.browsing.country.name.egypt" = "Egypt"; +"enum.browsing.country.name.elSalvador" = "El Salvador"; +"enum.browsing.country.name.equatorialGuinea" = "Equatorial Guinea"; +"enum.browsing.country.name.eritrea" = "Eritrea"; +"enum.browsing.country.name.estonia" = "Estonia"; +"enum.browsing.country.name.ethiopia" = "Ethiopia"; +"enum.browsing.country.name.europe" = "Europe"; +"enum.browsing.country.name.falklandIslands" = "Falkland Islands"; +"enum.browsing.country.name.faroeIslands" = "Faroe Islands"; +"enum.browsing.country.name.fiji" = "Fiji"; +"enum.browsing.country.name.finland" = "Finland"; +"enum.browsing.country.name.france" = "France"; +"enum.browsing.country.name.frenchGuiana" = "French Guiana"; +"enum.browsing.country.name.frenchPolynesia" = "French Polynesia"; +"enum.browsing.country.name.frenchSouthernTerritories" = "French Southern Territories"; +"enum.browsing.country.name.gabon" = "Gabon"; +"enum.browsing.country.name.gambia" = "Gambia"; +"enum.browsing.country.name.georgia" = "Georgia"; +"enum.browsing.country.name.germany" = "Germany"; +"enum.browsing.country.name.ghana" = "Ghana"; +"enum.browsing.country.name.gibraltar" = "Gibraltar"; +"enum.browsing.country.name.greece" = "Greece"; +"enum.browsing.country.name.greenland" = "Greenland"; +"enum.browsing.country.name.grenada" = "Grenada"; +"enum.browsing.country.name.guadeloupe" = "Guadeloupe"; +"enum.browsing.country.name.guam" = "Guam"; +"enum.browsing.country.name.guatemala" = "Guatemala"; +"enum.browsing.country.name.guernsey" = "Guernsey"; +"enum.browsing.country.name.guinea" = "Guinea"; +"enum.browsing.country.name.guineaBissau" = "Guinea-Bissau"; +"enum.browsing.country.name.guyana" = "Guyana"; +"enum.browsing.country.name.haiti" = "Haiti"; +"enum.browsing.country.name.heardIslandAndMcDonaldIslands" = "Heard Island and McDonald Islands"; +"enum.browsing.country.name.vaticanCityState" = "Vatican City State"; +"enum.browsing.country.name.honduras" = "Honduras"; +"enum.browsing.country.name.hongKong" = "Hong Kong"; +"enum.browsing.country.name.hungary" = "Hungary"; +"enum.browsing.country.name.iceland" = "Iceland"; +"enum.browsing.country.name.india" = "India"; +"enum.browsing.country.name.indonesia" = "Indonesia"; +"enum.browsing.country.name.iran" = "Iran"; +"enum.browsing.country.name.iraq" = "Iraq"; +"enum.browsing.country.name.ireland" = "Ireland"; +"enum.browsing.country.name.isleOfMan" = "Isle of Man"; +"enum.browsing.country.name.israel" = "Israel"; +"enum.browsing.country.name.italy" = "Italy"; +"enum.browsing.country.name.jamaica" = "Jamaica"; +"enum.browsing.country.name.japan" = "Japan"; +"enum.browsing.country.name.jersey" = "Jersey"; +"enum.browsing.country.name.jordan" = "Jordan"; +"enum.browsing.country.name.kazakhstan" = "Kazakhstan"; +"enum.browsing.country.name.kenya" = "Kenya"; +"enum.browsing.country.name.kiribati" = "Kiribati"; +"enum.browsing.country.name.kuwait" = "Kuwait"; +"enum.browsing.country.name.kyrgyzstan" = "Kyrgyzstan"; +"enum.browsing.country.name.laoPeoplesDemocraticRepublic" = "Lao People's Democratic Republic"; +"enum.browsing.country.name.latvia" = "Latvia"; +"enum.browsing.country.name.lebanon" = "Lebanon"; +"enum.browsing.country.name.lesotho" = "Lesotho"; +"enum.browsing.country.name.liberia" = "Liberia"; +"enum.browsing.country.name.libya" = "Libya"; +"enum.browsing.country.name.liechtenstein" = "Liechtenstein"; +"enum.browsing.country.name.lithuania" = "Lithuania"; +"enum.browsing.country.name.luxembourg" = "Luxembourg"; +"enum.browsing.country.name.macau" = "Macau"; +"enum.browsing.country.name.macedonia" = "Macedonia"; +"enum.browsing.country.name.madagascar" = "Madagascar"; +"enum.browsing.country.name.malawi" = "Malawi"; +"enum.browsing.country.name.malaysia" = "Malaysia"; +"enum.browsing.country.name.maldives" = "Maldives"; +"enum.browsing.country.name.mali" = "Mali"; +"enum.browsing.country.name.malta" = "Malta"; +"enum.browsing.country.name.marshallIslands" = "Marshall Islands"; +"enum.browsing.country.name.martinique" = "Martinique"; +"enum.browsing.country.name.mauritania" = "Mauritania"; +"enum.browsing.country.name.mauritius" = "Mauritius"; +"enum.browsing.country.name.mayotte" = "Mayotte"; +"enum.browsing.country.name.mexico" = "Mexico"; +"enum.browsing.country.name.micronesia" = "Micronesia"; +"enum.browsing.country.name.moldova" = "Moldova"; +"enum.browsing.country.name.monaco" = "Monaco"; +"enum.browsing.country.name.mongolia" = "Mongolia"; +"enum.browsing.country.name.montenegro" = "Montenegro"; +"enum.browsing.country.name.montserrat" = "Montserrat"; +"enum.browsing.country.name.morocco" = "Morocco"; +"enum.browsing.country.name.mozambique" = "Mozambique"; +"enum.browsing.country.name.myanmar" = "Myanmar"; +"enum.browsing.country.name.namibia" = "Namibia"; +"enum.browsing.country.name.nauru" = "Nauru"; +"enum.browsing.country.name.nepal" = "Nepal"; +"enum.browsing.country.name.netherlands" = "Netherlands"; +"enum.browsing.country.name.newCaledonia" = "New Caledonia"; +"enum.browsing.country.name.newZealand" = "New Zealand"; +"enum.browsing.country.name.nicaragua" = "Nicaragua"; +"enum.browsing.country.name.niger" = "Niger"; +"enum.browsing.country.name.nigeria" = "Nigeria"; +"enum.browsing.country.name.niue" = "Niue"; +"enum.browsing.country.name.norfolkIsland" = "Norfolk Island"; +"enum.browsing.country.name.northKorea" = "North Korea"; +"enum.browsing.country.name.northernMarianaIslands" = "Northern Mariana Islands"; +"enum.browsing.country.name.norway" = "Norway"; +"enum.browsing.country.name.oman" = "Oman"; +"enum.browsing.country.name.pakistan" = "Pakistan"; +"enum.browsing.country.name.palau" = "Palau"; +"enum.browsing.country.name.palestinianTerritory" = "Palestinian Territory"; +"enum.browsing.country.name.panama" = "Panama"; +"enum.browsing.country.name.papuaNewGuinea" = "Papua New Guinea"; +"enum.browsing.country.name.paraguay" = "Paraguay"; +"enum.browsing.country.name.peru" = "Peru"; +"enum.browsing.country.name.philippines" = "Philippines"; +"enum.browsing.country.name.pitcairnIslands" = "Pitcairn Islands"; +"enum.browsing.country.name.poland" = "Poland"; +"enum.browsing.country.name.portugal" = "Portugal"; +"enum.browsing.country.name.puertoRico" = "Puerto Rico"; +"enum.browsing.country.name.qatar" = "Qatar"; +"enum.browsing.country.name.reunion" = "Reunion"; +"enum.browsing.country.name.romania" = "Romania"; +"enum.browsing.country.name.russianFederation" = "Russian Federation"; +"enum.browsing.country.name.rwanda" = "Rwanda"; +"enum.browsing.country.name.saintBarthelemy" = "Saint Barthelemy"; +"enum.browsing.country.name.saintHelena" = "Saint Helena"; +"enum.browsing.country.name.saintKittsAndNevis" = "Saint Kitts and Nevis"; +"enum.browsing.country.name.saintLucia" = "Saint Lucia"; +"enum.browsing.country.name.saintMartin" = "Saint Martin"; +"enum.browsing.country.name.saintPierreAndMiquelon" = "Saint Pierre and Miquelon"; +"enum.browsing.country.name.saintVincentAndTheGrenadines" = "Saint Vincent and the Grenadines"; +"enum.browsing.country.name.samoa" = "Samoa"; +"enum.browsing.country.name.sanMarino" = "San Marino"; +"enum.browsing.country.name.saoTomeAndPrincipe" = "Sao Tome and Principe"; +"enum.browsing.country.name.saudiArabia" = "Saudi Arabia"; +"enum.browsing.country.name.senegal" = "Senegal"; +"enum.browsing.country.name.serbia" = "Serbia"; +"enum.browsing.country.name.seychelles" = "Seychelles"; +"enum.browsing.country.name.sierraLeone" = "Sierra Leone"; +"enum.browsing.country.name.singapore" = "Singapore"; +"enum.browsing.country.name.sintMaarten" = "Sint Maarten"; +"enum.browsing.country.name.slovakia" = "Slovakia"; +"enum.browsing.country.name.slovenia" = "Slovenia"; +"enum.browsing.country.name.solomonIslands" = "Solomon Islands"; +"enum.browsing.country.name.somalia" = "Somalia"; +"enum.browsing.country.name.southAfrica" = "South Africa"; +"enum.browsing.country.name.southGeorgiaAndTheSouthSandwichIslands" = "South Georgia and the South Sandwich Islands"; +"enum.browsing.country.name.southKorea" = "South Korea"; +"enum.browsing.country.name.southSudan" = "South Sudan"; +"enum.browsing.country.name.spain" = "Spain"; +"enum.browsing.country.name.sriLanka" = "Sri Lanka"; +"enum.browsing.country.name.sudan" = "Sudan"; +"enum.browsing.country.name.suriname" = "Suriname"; +"enum.browsing.country.name.svalbardAndJanMayen" = "Svalbard and Jan Mayen"; +"enum.browsing.country.name.swaziland" = "Swaziland"; +"enum.browsing.country.name.sweden" = "Sweden"; +"enum.browsing.country.name.switzerland" = "Switzerland"; +"enum.browsing.country.name.syrianArabRepublic" = "Syrian Arab Republic"; +"enum.browsing.country.name.taiwan" = "Taiwan"; +"enum.browsing.country.name.tajikistan" = "Tajikistan"; +"enum.browsing.country.name.tanzania" = "Tanzania"; +"enum.browsing.country.name.thailand" = "Thailand"; +"enum.browsing.country.name.timorLeste" = "Timor-Leste"; +"enum.browsing.country.name.togo" = "Togo"; +"enum.browsing.country.name.tokelau" = "Tokelau"; +"enum.browsing.country.name.tonga" = "Tonga"; +"enum.browsing.country.name.trinidadAndTobago" = "Trinidad and Tobago"; +"enum.browsing.country.name.tunisia" = "Tunisia"; +"enum.browsing.country.name.turkey" = "Turkey"; +"enum.browsing.country.name.turkmenistan" = "Turkmenistan"; +"enum.browsing.country.name.turksAndCaicosIslands" = "Turks and Caicos Islands"; +"enum.browsing.country.name.tuvalu" = "Tuvalu"; +"enum.browsing.country.name.uganda" = "Uganda"; +"enum.browsing.country.name.ukraine" = "Ukraine"; +"enum.browsing.country.name.unitedArabEmirates" = "United Arab Emirates"; +"enum.browsing.country.name.unitedKingdom" = "United Kingdom"; +"enum.browsing.country.name.unitedStates" = "United States"; +"enum.browsing.country.name.unitedStatesMinorOutlyingIslands" = "United States Minor Outlying Islands"; +"enum.browsing.country.name.uruguay" = "Uruguay"; +"enum.browsing.country.name.uzbekistan" = "Uzbekistan"; +"enum.browsing.country.name.vanuatu" = "Vanuatu"; +"enum.browsing.country.name.venezuela" = "Venezuela"; +"enum.browsing.country.name.vietnam" = "Vietnam"; +"enum.browsing.country.name.virginIslandsBritish" = "British Virgin Islands"; +"enum.browsing.country.name.virginIslandsUS" = "U.S. Virgin Islands"; +"enum.browsing.country.name.wallisAndFutuna" = "Wallis and Futuna"; +"enum.browsing.country.name.westernSahara" = "Western Sahara"; +"enum.browsing.country.name.yemen" = "Yemen"; +"enum.browsing.country.name.zambia" = "Zambia"; +"enum.browsing.country.name.zimbabwe" = "Zimbabwe"; diff --git a/EhPanda/App/en.lproj/InfoPlist.strings b/EhPanda/App/en.lproj/InfoPlist.strings index 22188633..d290e3a2 100644 --- a/EhPanda/App/en.lproj/InfoPlist.strings +++ b/EhPanda/App/en.lproj/InfoPlist.strings @@ -5,3 +5,6 @@ Created by 荒木辰造 on R 3/02/09. */ + +"NSFaceIDUsageDescription" = "We need this permission to provide Face ID option while unlocking the App."; +"NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 51fc4dc0..39152b95 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -6,46 +6,873 @@ */ +// MARK: BanInterval +"enum.ban.interval.description.and" = "and"; + +// MARK: ToplistsType +"enum.toplists.type.value.yesterday" = "Yesterday"; +"enum.toplists.type.value.pastMonth" = "Past month"; +"enum.toplists.type.value.pastYear" = "Past year"; +"enum.toplists.type.value.allTime" = "All time"; + // MARK: Response -"You must have a H@H client assigned to your account to use this feature." = "You must have a Hath client assigned to your account to use this feature."; -"Your H@H client appears to be offline. Turn it on, then try again." = "Your Hath client appears to be offline. Turn it on, then try again."; +"hath.download.response.hathClientNotFound" = "You must have a H@H client assigned to your account to use this feature."; +"hath.download.response.hathClientNotOnline" = "Your H@H client appears to be offline. Turn it on, then try again."; +"hath.download.response.invalidResolution" = "The requested gallery cannot be downloaded with the selected resolution."; -// MARK: Common -"null" = "None"; -"expired" = "Expired"; -"mystery" = "Rejected"; +// MARK: HUD +"hud.title.error" = "Error"; +"hud.title.success" = "Success"; +"hud.title.loading" = "Loading..."; +"hud.title.communicating" = "Communicating..."; +"hud.caption.copiedToClipboard" = "Copied to clipboard"; +"hud.caption.savedToPhotoLibrary" = "Saved to photo library"; -// MARK: User -"favoriteNameByDev" = "Favorite"; -"all_appendedByDev" = "All"; +// MARK: AutoLock +"local.authorization.reason" = "The App has been locked due to the Auto-Lock expiration."; -// MARK: DetailView -"DESC_SCROLL_ITEM_FAVORITED" = "Favorited"; +// MARK: Common value +"common.value.stars" = "%@ stars"; +"common.value.pages" = "%@ pages"; +"common.value.times" = "%@ times"; +"common.value.day" = "%@ day"; +"common.value.days" = "%@ days"; +"common.value.hour" = "%@ hour"; +"common.value.hours" = "%@ hours"; +"common.value.minute" = "%@ minute"; +"common.value.minutes" = "%@ minutes"; +"common.value.second" = "%@ second"; +"common.value.seconds" = "%@ seconds"; +"common.value.records" = "%@ records"; + +// MARK: TabItem +"tab.item.title.home" = "Home"; +"tab.item.title.favorites" = "Favorites"; +"tab.item.title.search" = "Search"; +"tab.item.title.setting" = "Setting"; + +// MARK: ToolbarItem +"toolbar.item.button.filters" = "Filters"; +"toolbar.item.button.jumpPage" = "Jump page"; +"toolbar.item.button.quickSearch" = "Quick search"; + +// MARK: JumpPage +"jump.page.view.title.jumpPage" = "Jump page"; +"jump.page.view.button.confirm" = "Confirm"; + +// MARK: AlertView +"loading.view.title.loading" = "Loading..."; +"loading.view.title.preparingDatabase" = "Preparing the database..."; +"not.login.view.title.needLogin" = "You need to login to access this feature."; +"not.login.view.button.login" = "Login"; +"error.view.button.retry" = "Retry"; +"error.view.button.dropDatabase" = "Drop the database"; +"error.view.title.tryLater" = "Please try again later."; +"error.view.title.network" = "A network error occurred."; +"error.view.title.parsing" = "A parsing error occurred."; +"error.view.title.unknown" = "An unknown error occurred."; +"error.view.title.notFound" = "There seems to be nothing here."; +"error.view.title.databaseCorrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; +"error.view.title.ipBanned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; +"error.view.title.copyrightClaim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; +"error.view.title.galleryUnavailable" = "This gallery has been removed or is unavailable."; -// MARK: ArchiveView -"ARCHIVE_RESOLUTION_ORIGINAL" = "Original"; +// MARK: ConfirmationDialog +"confirmation.dialog.title.dropDatabase" = "You will lose all your data in this app.\nAre you sure to drop the database?"; +"confirmation.dialog.title.removeCustomTranslations" = "Are you sure to remove your custom translations?"; +"confirmation.dialog.title.logout" = "Are you sure to logout?"; +"confirmation.dialog.title.delete" = "Are you sure to delete this item?"; +"confirmation.dialog.title.clear" = "Are you sure to clear?"; +"confirmation.dialog.title.reset" = "Are you sure to reset?"; +"confirmation.dialog.button.dropDatabase" = "Drop the database"; +"confirmation.dialog.button.remove" = "Remove"; +"confirmation.dialog.button.logout" = "Logout"; +"confirmation.dialog.button.delete" = "Delete"; +"confirmation.dialog.button.clear" = "Clear"; +"confirmation.dialog.button.reset" = "Reset"; + +// MARK: SubSection +"sub.section.button.showAll" = "Show all"; // MARK: NewDawnView -"GAINCONTENT_START" = "You gain "; -"GAINCONTENT_SEPARATOR" = ", "; -"GAINCONTENT_AND" = " and "; -"GAINCONTENT_END" = "!"; +"new.dawn.view.title.first" = "It is the dawn of a new day!"; +"new.dawn.view.title.second" = "Reflecting on your journey so far, you find that you are a little wiser."; +// Greeting +"struct.greeting.mark.start" = "You gain "; +"struct.greeting.mark.separator" = ", "; +"struct.greeting.mark.and" = " and "; +"struct.greeting.mark.end" = "!"; + +// MARK: HomeView +"home.view.title.home" = "Home"; +"home.view.section.title.frontpage" = "Frontpage"; +"home.view.section.title.toplists" = "Toplists"; +"home.view.section.title.other" = "Other"; +// HomeMiscGridType +"home.misc.grid.type.title.popular" = "Popular"; +"home.misc.grid.type.title.watched" = "Watched"; +"home.misc.grid.type.title.history" = "History"; + +// MARK: FrontpageView +"frontpage.view.title.frontpage" = "Frontpage"; + +// MARK: ToplistsView +"toplists.view.title.toplists" = "Toplists"; + +// MARK: PopularView +"popular.view.title.popular" = "Popular"; + +// MARK: WatchedView +"watched.view.title.watched" = "Watched"; + +// MARK: HistoryView +"history.view.title.history" = "History"; + +// MARK: FavoritesView +"favorites.view.title.favorites" = "Favorites"; +// FavoriteCategory +"favorite.category.default" = "Favorites %@"; +"favorite.category.all" = "All"; + +// MARK: SearchView +"search.view.title.search" = "Search"; +"search.view.section.title.recentlySearched" = "Recently searched"; +"search.view.section.title.recentlySeen" = "Recently seen"; +"search.view.section.title.quickSearch" = "Quick search"; +// Searchable prompt +"searchable.prompt.filter" = "Filter"; + +// MARK: QuickSearchView +"quick.search.view.title.quickSearch" = "Quick search"; +"quick.search.view.title.editWord" = "Edit word"; +"quick.search.view.title.newWord" = "New word"; +"quick.search.view.title.content" = "Content"; +"quick.search.view.title.name" = "Name"; +"quick.search.view.placeholder.optional" = "Optional"; +"quick.search.view.toolbar.item.button.confirm" = "Confirm"; + +// MARK: SettingView +"setting.view.title.setting" = "Setting"; +// SettingStateRoute +"enum.setting.state.route.value.account" = "Account"; +"enum.setting.state.route.value.general" = "General"; +"enum.setting.state.route.value.appearance" = "Appearance"; +"enum.setting.state.route.value.reading" = "Reading"; +"enum.setting.state.route.value.laboratory" = "Laboratory"; +"enum.setting.state.route.value.ehPanda" = "About EhPanda"; + +// MARK: AccountSettingView +"account.setting.view.title.account" = "Account"; +"account.setting.view.title.showsNewDawnGreeting" = "Shows new dawn greeting"; +"account.setting.view.button.login" = "Login"; +"account.setting.view.button.logout" = "Logout"; +"account.setting.view.button.accountConfiguration" = "Account configuration"; +"account.setting.view.button.tagsManagement" = "Manage tags subscription"; +"account.setting.view.button.copyCookies" = "Copy cookies"; +// CookieValue +"struct.cookie.value.localized.string.expired" = "Expired"; +"struct.cookie.value.localized.string.mystery" = "Rejected"; +"struct.cookie.value.localized.string.none" = "None"; + +// MARK: LoginView +"login.view.title.login" = "Login"; +"login.view.title.username" = "Username"; +"login.view.title.password" = "Password"; + +// MARK: GeneralSettingView +"general.setting.view.title.general" = "General"; +"general.setting.view.title.language" = "Language"; +"general.setting.view.title.autoLock" = "Auto-Lock"; +"general.setting.view.title.translatesTags" = "Translates tags"; +"general.setting.view.title.redirectsLinksToTheSelectedHost" = "Redirects links to the selected host"; +"general.setting.view.title.detectsLinksFromClipboard" = "Detects links from the clipboard"; +"general.setting.view.title.backgroundBlurRadius" = "Background blur radius"; +"general.setting.view.button.logs" = "Logs"; +"general.setting.view.button.importCustomTranslations" = "Import custom translations"; +"general.setting.view.button.removeCustomTranslations" = "Remove custom translations"; +"general.setting.view.button.clearImageCaches" = "Clear image caches"; +"general.setting.view.value.defaultLanguageDescription" = "N/A"; +"general.setting.view.section.title.tagsTranslation" = "Tags translation"; +"general.setting.view.section.title.navigation" = "Navigation"; +"general.setting.view.section.title.security" = "Security"; +"general.setting.view.section.title.caches" = "Caches"; +// AutoLockPolicy +"enum.auto.lock.policy.value.never" = "Never"; +"enum.auto.lock.policy.value.instantly" = "Instantly"; + +// MARK: LogsView +"logs.view.title.logs" = "Logs"; +"logs.view.title.latest" = "Latest"; + +// MARK: AppearanceSettingView +"appearance.setting.view.title.appearance" = "Appearance"; +"appearance.setting.view.title.theme" = "Theme"; +"appearance.setting.view.title.tintColor" = "Tint color"; +"appearance.setting.view.title.displayMode" = "Display mode"; +"appearance.setting.view.title.showsTagsInList" = "Shows tags in list"; +"appearance.setting.view.title.maximumNumberOfTags" = "Maximum number of tags"; +"appearance.setting.view.button.appIcon" = "App icon"; +"appearance.setting.view.menu.title.infite" = "Infite"; +"appearance.setting.view.section.title.list" = "List"; +// PerferredColorScheme +"enum.perferred.color.scheme.value.automatic" = "Automatic"; +"enum.perferred.color.scheme.value.light" = "Light"; +"enum.perferred.color.scheme.value.dark" = "Dark"; +// AppIconType +"enum.app.icon.type.value.default" = "Default"; +"enum.app.icon.type.value.ukiyoe" = "Ukiyo-e"; +// ListDisplayMode +"enum.display.mode.value.detail" = "Detail"; +"enum.display.mode.value.thumbnail" = "Thumbnail"; + +// MARK: AppIconView +"app.icon.view.title.appIcon" = "App icon"; + +// MARK: ReadingSettingView +"reading.setting.view.title.reading" = "Reading"; +"reading.setting.view.title.direction" = "Direction"; +"reading.setting.view.title.preloadLimit" = "Preload limit"; +"reading.setting.view.title.enablesLandscape" = "Enables landscape"; +"reading.setting.view.title.separatorHeight" = "Separator height"; +"reading.setting.view.title.maximumScaleFactor" = "Maximum scale factor"; +"reading.setting.view.title.doubleTapScaleFactor" = "Double tap scale factor"; +"reading.setting.view.section.title.appearance" = "Appearance"; +// ReadingDirection +"enum.reading.direction.value.vertical" = "Vertical"; +"enum.reading.direction.value.rightToLeft" = "Right-to-left"; +"enum.reading.direction.value.leftToRight" = "Left-to-right"; + +// MARK: LaboratorySettingView +"laboratory.setting.view.title.laboratory" = "Laboratory"; +"laboratory.setting.view.title.bypassesSNIFiltering" = "Bypasses SNI Filtering"; + +// MARK: EhPandaView +"ehpanda.view.title.ehPanda" = "EhPanda"; +"ehpanda.view.button.website" = "Website"; +"ehpanda.view.button.altStoreSource" = "AltStore source"; +"ehpanda.view.description.version" = "Version"; +"ehpanda.view.section.title.specialThanks" = "Special thanks"; +"ehpanda.view.section.title.codeLevelContributors" = "Code-level contributors"; +"ehpanda.view.section.title.translationContributors" = "Translation contributors"; +"ehpanda.view.section.title.acknowledgements" = "Acknowledgements"; + +// MARK: DetailView +"detail.view.button.read" = "Read"; +"detail.view.button.postComment" = "Post comment"; +"detail.view.toolbar.item.button.archives" = "Archives"; +"detail.view.toolbar.item.button.torrents" = "Torrents"; +"detail.view.toolbar.item.button.share" = "Share"; +"detail.view.scroll.section.title.favorited" = "Favorited"; +"detail.view.scroll.section.title.language" = "Language"; +"detail.view.scroll.section.title.ratings" = "%@ Ratings"; +"detail.view.scroll.section.title.pageCount" = "Page Count"; +"detail.view.scroll.section.title.fileSize" = "File Size"; +"detail.view.scroll.section.description.favorited" = "Times"; +"detail.view.scroll.section.description.pageCount" = "Pages"; +"detail.view.action.section.button.giveARating" = "Give a Rating"; +"detail.view.action.section.button.similarGallery" = "Similar Gallery"; +"detail.view.section.title.previews" = "Previews"; +"detail.view.section.title.comments" = "Comments"; + +// MARK: ArchivesView +"archives.view.title.archives" = "Archives"; +"archives.view.button.downloadToHathClient" = "Download To H@H Client"; +// HathArchive +"struct.hath.archive.price.value.free" = "Free"; +"struct.hath.archive.price.value.notAvailable" = "N/A"; +"struct.hath.archive.resolution.value.original" = "Original"; -// MARK: Setting -"LIST_DISPLAY_MODE_DETAIL" = "Detail"; -"LIST_DISPLAY_MODE_THUMBNAIL" = "Thumbnail"; -"READING_DIRECTION_VERTICAL" = "Vertical"; +// MARK: TorrentsView +"torrents.view.title.torrents" = "Torrents"; + +// MARK: GalleryInfosView +"gallery.infos.view.title.galleryInfos" = "Gallery infos"; +"gallery.infos.view.title.ID" = "ID"; +"gallery.infos.view.title.token" = "Token"; +"gallery.infos.view.title.title" = "Title"; +"gallery.infos.view.title.japaneseTitle" = "Japanese title"; +"gallery.infos.view.title.galleryURL" = "Gallery URL"; +"gallery.infos.view.title.coverURL" = "Cover URL"; +"gallery.infos.view.title.archiveURL" = "Archive URL"; +"gallery.infos.view.title.torrentURL" = "Torrent URL"; +"gallery.infos.view.title.parentURL" = "Parent URL"; +"gallery.infos.view.title.category" = "Category"; +"gallery.infos.view.title.uploader" = "Uploader"; +"gallery.infos.view.title.postedDate" = "Posted date"; +"gallery.infos.view.title.visibility" = "Visibility"; +"gallery.infos.view.title.language" = "Language"; +"gallery.infos.view.title.pageCount" = "Page count"; +"gallery.infos.view.title.fileSize" = "File size"; +"gallery.infos.view.title.favoritedTimes" = "Favorited times"; +"gallery.infos.view.title.favorited" = "Favorited"; +"gallery.infos.view.title.ratingCount" = "Rating count"; +"gallery.infos.view.title.averageRating" = "Average rating"; +"gallery.infos.view.title.myRating" = "My rating"; +"gallery.infos.view.title.torrentCount" = "Torrent count"; +"gallery.infos.view.value.none" = "None"; +"gallery.infos.view.value.yes" = "Yes"; +"gallery.infos.view.value.no" = "No"; +// GalleryVisibility +"gallery.visibility.value.yes" = "Yes"; +"gallery.visibility.value.no" = "No (%@)"; +"gallery.visibility.value.no.reason.expunged" = "Expunged"; + +// MARK: CommentsView +"comments.view.title.comments" = "Comments"; + +// MARK: PostCommentView +"post.comment.view.title.postComment" = "Post comment"; +"post.comment.view.title.editComment" = "Edit comment"; +"post.comment.view.button.cancel" = "Cancel"; +"post.comment.view.button.post" = "Post"; + +// MARK: PreviewsView +"previews.view.title.previews" = "Previews"; + +// MARK: ReadingView +"reading.view.context.menu.button.reload" = "Reload"; +"reading.view.context.menu.button.copy" = "Copy"; +"reading.view.context.menu.button.save" = "Save"; +"reading.view.context.menu.button.saveOriginal" = "Save original"; +"reading.view.context.menu.button.share" = "Share"; +"reading.view.toolbar.item.title.autoPlay" = "Auto-Play"; +"reading.view.toolbar.item.title.dualPageMode" = "Dual-Page mode"; +"reading.view.toolbar.item.title.exceptTheCover" = "Except the cover"; +// AutoPlayPolicy +"enum.auto.play.policy.value.off" = "Off"; + +// MARK: FiltersView +"filters.view.title.filters" = "Filters"; +"filters.view.title.advancedSettings" = "Advanced settings"; +"filters.view.title.searchGalleryName" = "Search gallery name"; +"filters.view.title.searchGalleryTags" = "Search gallery tags"; +"filters.view.title.searchGalleryDescription" = "Search gallery description"; +"filters.view.title.searchTorrentFilenames" = "Search torrent filenames"; +"filters.view.title.onlyShowGalleriesWithTorrents" = "Only show galleries with torrents"; +"filters.view.title.searchLowPowerTags" = "Search Low-Power tags"; +"filters.view.title.searchDownvotedTags" = "Search downvoted tags"; +"filters.view.title.showExpungedGalleries" = "Search expunged galleries"; +"filters.view.title.setMinimumRating" = "Set minimum rating"; +"filters.view.title.minimumRating" = "Minimum rating"; +"filters.view.title.setPagesRange" = "Set pages range"; +"filters.view.title.pagesRange" = "Pages range"; +"filters.view.title.disableLanguageFilter" = "Disable language filter"; +"filters.view.title.disableUploaderFilter" = "Disable uploader filter"; +"filters.view.title.disableTagsFilter" = "Disable tags filter"; +"filters.view.button.resetFilters" = "Reset filters"; +"filters.view.section.title.advanced" = "Advanced"; +"filters.view.section.title.defaultFilter" = "Default filter"; +// FilterRange +"enum.filter.range.value.search" = "Search"; +"enum.filter.range.value.global" = "Global"; +"enum.filter.range.value.watched" = "Watched"; // MARK: EhSettingView -"LOAD_THROUGH_HATH_NO" = "No"; +"eh.setting.view.title.hostSetting" = "%@ setting"; +"eh.setting.view.section.title.profileSettings" = "Profile Settings"; +"eh.setting.view.title.selectedProfile" = "Selected profile"; +"eh.setting.view.button.setAsDefault" = "Set as default"; +"eh.setting.view.button.deleteProfile" = "Delete profile"; +"eh.setting.view.button.rename" = "Rename"; +"eh.setting.view.button.createNew" = "Create new"; +"eh.setting.view.toolbar.item.button.done" = "Done"; -// MARK: AlertView -"BAN_INTERVAL_AND" = " and "; -"BAN_INTERVAL_DAYS" = " days"; -"BAN_INTERVAL_HOURS" = " hours"; -"BAN_INTERVAL_MINUTES" = " minutes"; -"BAN_INTERVAL_SECONDS" = " seconds"; +"eh.setting.view.section.title.imageLoadSettings" = "Image Load Settings"; +"eh.setting.view.title.loadImagesThroughTheHathNetwork" = "Load images through the Hath network"; +"eh.setting.view.title.browsingCountry" = "Browsing country"; +"eh.setting.view.description.browsingCountry" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; +// EhSetting.LoadThroughHathSetting +"enum.eh.setting.load.through.hath.setting.value.anyClient" = "Any client"; +"enum.eh.setting.load.through.hath.setting.value.defaultPortOnly" = "Default port clients only"; +"enum.eh.setting.load.through.hath.setting.value.modernNo" = "No [Modern/HTTPS]"; +"enum.eh.setting.load.through.hath.setting.value.legacyNo" = "No [Legacy/HTTP]"; +"enum.eh.setting.load.through.hath.setting.description.anyClient" = "Recommended."; +"enum.eh.setting.load.through.hath.setting.description.defaultPortOnly" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; +"enum.eh.setting.load.through.hath.setting.description.modernNo" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; +"enum.eh.setting.load.through.hath.setting.description.legacyNo" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; + +"eh.setting.view.section.title.imageSizeSettings" = "Image Size Settings"; +"eh.setting.view.title.imageResolution" = "Image resolution"; +"eh.setting.view.description.imageResolution" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; +"eh.setting.view.title.imageSize" = "Image size"; +"eh.setting.view.description.imageSize" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; +"eh.setting.view.title.horizontal" = "Horizontal"; +"eh.setting.view.title.vertical" = "Vertical"; +// EhSetting.ImageResolution +"enum.eh.setting.image.resolution.value.auto" = "Auto"; + +"eh.setting.view.section.title.galleryNameDisplay" = "Gallery Name Display"; +"eh.setting.view.title.galleryName" = "Gallery name"; +"eh.setting.view.description.galleryName" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; +// EhSetting.GalleryName +"enum.eh.setting.gallery.name.value.default" = "Default Title"; +"enum.eh.setting.gallery.name.value.japanese" = "Japanese Title (if available)"; + +"eh.setting.view.section.title.archiverSettings" = "Archiver Settings"; +"eh.setting.view.title.archiverBehavior" = "Archiver behavior"; +"eh.setting.view.description.archiverBehavior" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; +// EhSetting.ArchiverBehavior +"enum.eh.setting.archiver.behavior.value.manualSelectManualStart" = "Manual Select, Manual Start (Default)"; +"enum.eh.setting.archiver.behavior.value.manualSelectAutoStart" = "Manual Select, Auto Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalManualStart" = "Auto Select Original, Manual Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalAutoStart" = "Auto Select Original, Auto Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleManualStart" = "Auto Select Resample, Manual Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleAutoStart" = "Auto Select Resample, Auto Start"; + +"eh.setting.view.section.title.frontPageSettings" = "Front Page Settings"; +"eh.setting.view.title.displayMode" = "Display mode"; +"eh.setting.view.description.displayMode" = "Which display mode would you like to use on the front and search pages?"; +"eh.setting.view.description.galleryCategory" = "What categories would you like to show by default on the front page and in searches?"; +// EhSetting.DisplayMode +"enum.eh.setting.display.mode.value.compact" = "Compact"; +"enum.eh.setting.display.mode.value.thumbnail" = "Thumbnail"; +"enum.eh.setting.display.mode.value.extended" = "Extended"; +"enum.eh.setting.display.mode.value.minimal" = "Minimal"; +"enum.eh.setting.display.mode.value.minimalPlus" = "Minimal+"; + +"eh.setting.view.section.title.favorites" = "Favorites"; +"eh.setting.view.description.favoriteCategories" = "Here you can choose and rename your favorite categories."; +"eh.setting.view.title.favoritesSortOrder" = "Favorites sort order"; +"eh.setting.view.description.favoritesSortOrder" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; +// EhSetting.FavoritesSortOrder +"enum.eh.setting.favorites.sort.order.value.lastUpdateTime" = "By last gallery update time"; +"enum.eh.setting.favorites.sort.order.value.favoritedTime" = "By favorited time"; + +"eh.setting.view.section.title.ratings" = "Ratings"; +"eh.setting.view.title.ratingsColor" = "Ratings color"; +"eh.setting.view.promt.ratingsColor" = "RRGGB"; +"eh.setting.view.description.ratingsColor" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; + +"eh.setting.view.section.title.tagsNamespaces" = "Tag Namespaces"; +"eh.setting.view.description.tagsNamespaces" = "If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces."; + +"eh.setting.view.section.title.tagFilteringThreshold" = "Tag Filtering Threshold"; +"eh.setting.view.title.tagFilteringThreshold" = "Tag Filtering Threshold"; +"eh.setting.view.description.tagFilteringThreshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; + +"eh.setting.view.section.title.tagWatchingThreshold" = "Tag Watching Threshold"; +"eh.setting.view.title.tagWatchingThreshold" = "Tag Watching Threshold"; +"eh.setting.view.description.tagWatchingThreshold" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; + +"eh.setting.view.section.title.excludedLanguages" = "Excluded Languages"; +"eh.setting.view.description.excludedLanguages" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; +// EhSetting.ExcludedLanguagesCategory +"enum.eh.setting.excluded.languages.category.value.original" = "Original"; +"enum.eh.setting.excluded.languages.category.value.translated" = "Translated"; +"enum.eh.setting.excluded.languages.category.value.rewrite" = "Rewrite"; + +"eh.setting.view.section.title.excludedUploaders" = "Excluded Uploaders"; +"eh.setting.view.description.excludedUploaders" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; +"eh.setting.view.description.excludedUploadersCount" = "You are currently using **%@ / %@** exclusion slots."; + +"eh.setting.view.section.title.searchResultCount" = "Search Result Count"; +"eh.setting.view.title.resultCount" = "Result count"; +"eh.setting.view.description.resultCount" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; + +"eh.setting.view.section.title.thumbnailSettings" = "Thumbnail Settings"; +"eh.setting.view.title.thumbnailLoadTiming" = "Thumbnail load timing"; +"eh.setting.view.description.thumbnailLoadTiming" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; +"eh.setting.view.description.thumbnailConfiguration" = "You can set a default thumbnail configuration for all galleries you visit."; +"eh.setting.view.title.thumbnailSize" = "Size"; +"eh.setting.view.title.thumbnailRowCount" = "Rows"; +// EhSetting.ThumbnailLoadTiming +"enum.eh.setting.thumbnail.load.timing.value.onMouseOver" = "On mouse-over"; +"enum.eh.setting.thumbnail.load.timing.value.onPageLoad" = "On page load"; +"enum.eh.setting.thumbnail.load.timing.description.onMouseOver" = "Pages load faster, but there may be a slight delay before a thumb appears."; +"enum.eh.setting.thumbnail.load.timing.description.onPageLoad" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; +// EhSetting.ThumbnailSize +"enum.eh.setting.thumbnail.size.value.normal" = "Normal"; +"enum.eh.setting.thumbnail.size.value.large" = "Large"; + +"eh.setting.view.section.title.thumbnailScaling" = "Thumbnail Scaling"; +"eh.setting.view.title.scaleFactor" = "Scale factor"; +"eh.setting.view.description.scaleFactor" = "Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%."; + +"eh.setting.view.section.title.viewportOverride" = "Viewport Override"; +"eh.setting.view.title.virtualWidth" = "Virtual width"; +"eh.setting.view.description.virtualWidth" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400."; + +"eh.setting.view.section.title.galleryComments" = "Gallery Comments"; +"eh.setting.view.title.commentsSortOrder" = "Comments sort order"; +"eh.setting.view.title.commentsVotesShowTiming" = "Comment votes show timing"; +// EhSetting.CommentsSortOrder +"enum.eh.setting.comments.sort.order.value.oldest" = "Oldest comments first"; +"enum.eh.setting.comments.sort.order.value.recent" = "Recent comments first"; +"enum.eh.setting.comments.sort.order.value.highestScore" = "By highest score"; +// EhSetting.CommentVotesShowTiming +"enum.eh.setting.comments.votes.show.timing.value.onHoverOrClick" = "On score hover or click"; +"enum.eh.setting.comments.votes.show.timing.value.always" = "Always"; + +"eh.setting.view.section.title.galleryTags" = "Gallery Tags"; +"eh.setting.view.title.tagsSortOrder" = "Tags sort order"; +// EhSetting.TagsSortOrder +"enum.eh.setting.tags.sort.order.value.alphabetical" = "Alphabetical"; +"enum.eh.setting.tags.sort.order.value.tagPower" = "By tag power"; + +"eh.setting.view.section.title.galleryPageNumbering" = "Gallery Page Numbering"; +"eh.setting.view.title.showGalleryPageNumbers" = "Show gallery page numbers"; + +"eh.setting.view.section.title.hathLocalNetworkHost" = "Hath Local Network Host"; +"eh.setting.view.title.ipAddressPort" = "IP address:Port"; +"eh.setting.view.description.ipAddressPort" = "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work."; + +"eh.setting.view.section.title.originalImages" = "Original Images"; +"eh.setting.view.title.useOriginalImages" = "Use original images"; + +"eh.setting.view.section.title.multiPageViewer" = "Multi-Page Viewer"; +"eh.setting.view.title.useMultiPageViewer" = "Use Multi-Page Viewer"; +"eh.setting.view.title.displayStyle" = "Display style"; +"eh.setting.view.title.showThumbnailPane" = "Show thumbnail pane"; +// EhSetting.MultiplePageViewerStyle +"enum.eh.setting.multiple.page.viewer.style.value.alignLeftScaleIfOverWidth" = "Align left, scale if overwidth"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterScaleIfOverWidth" = "Align center, scale if overwidth"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterAlwaysScale" = "Align center, always scale"; + +// MARK: Category +"enum.category.value.doujinshi" = "Doujinshi"; +"enum.category.value.manga" = "Manga"; +"enum.category.value.artistCG" = "Artist CG"; +"enum.category.value.gameCG" = "Game CG"; +"enum.category.value.western" = "Western"; +"enum.category.value.nonH" = "Non-H"; +"enum.category.value.imageSet" = "Image Set"; +"enum.category.value.cosplay" = "Cosplay"; +"enum.category.value.asianPorn" = "Asian Porn"; +"enum.category.value.misc" = "Misc"; +"enum.category.value.private" = "Private"; + +// MARK: TagCategory +"enum.tag.category.value.reclass" = "Reclass"; +"enum.tag.category.value.language" = "Language"; +"enum.tag.category.value.parody" = "Parody"; +"enum.tag.category.value.character" = "Character"; +"enum.tag.category.value.group" = "Group"; +"enum.tag.category.value.artist" = "Artist"; +"enum.tag.category.value.male" = "Male"; +"enum.tag.category.value.female" = "Female"; +"enum.tag.category.value.mixed" = "Mixed"; +"enum.tag.category.value.cosplayer" = "Cosplayer"; +"enum.tag.category.value.other" = "Other"; +"enum.tag.category.value.temp" = "Temp"; // MARK: Language -"LANGUAGE_OTHER" = "Other"; -"LANGUAGE_INVALID" = "N/A"; +"enum.language.value.invalid" = "N/A"; +"enum.language.value.other" = "Other"; +"enum.language.value.afrikaans" = "Afrikaans"; +"enum.language.value.albanian" = "Albanian"; +"enum.language.value.arabic" = "Arabic"; +"enum.language.value.bengali" = "Bengali"; +"enum.language.value.bosnian" = "Bosnian"; +"enum.language.value.bulgarian" = "Bulgarian"; +"enum.language.value.burmese" = "Burmese"; +"enum.language.value.catalan" = "Catalan"; +"enum.language.value.cebuano" = "Cebuano"; +"enum.language.value.chinese" = "Chinese"; +"enum.language.value.croatian" = "Croatian"; +"enum.language.value.czech" = "Czech"; +"enum.language.value.danish" = "Danish"; +"enum.language.value.dutch" = "Dutch"; +"enum.language.value.english" = "English"; +"enum.language.value.esperanto" = "Esperanto"; +"enum.language.value.estonian" = "Estonian"; +"enum.language.value.finnish" = "Finnish"; +"enum.language.value.french" = "French"; +"enum.language.value.georgian" = "Georgian"; +"enum.language.value.german" = "German"; +"enum.language.value.greek" = "Greek"; +"enum.language.value.hebrew" = "Hebrew"; +"enum.language.value.hindi" = "Hindi"; +"enum.language.value.hmong" = "Hmong"; +"enum.language.value.hungarian" = "Hungarian"; +"enum.language.value.indonesian" = "Indonesian"; +"enum.language.value.italian" = "Italian"; +"enum.language.value.japanese" = "Japanese"; +"enum.language.value.kazakh" = "Kazakh"; +"enum.language.value.khmer" = "Khmer"; +"enum.language.value.korean" = "Korean"; +"enum.language.value.kurdish" = "Kurdish"; +"enum.language.value.lao" = "Lao"; +"enum.language.value.latin" = "Latin"; +"enum.language.value.mongolian" = "Mongolian"; +"enum.language.value.ndebele" = "Ndebele"; +"enum.language.value.nepali" = "Nepali"; +"enum.language.value.norwegian" = "Norwegian"; +"enum.language.value.oromo" = "Oromo"; +"enum.language.value.pashto" = "Pashto"; +"enum.language.value.persian" = "Persian"; +"enum.language.value.polish" = "Polish"; +"enum.language.value.portuguese" = "Portuguese"; +"enum.language.value.punjabi" = "Punjabi"; +"enum.language.value.romanian" = "Romanian"; +"enum.language.value.russian" = "Russian"; +"enum.language.value.sango" = "Sango"; +"enum.language.value.serbian" = "Serbian"; +"enum.language.value.shona" = "Shona"; +"enum.language.value.slovak" = "Slovak"; +"enum.language.value.slovenian" = "Slovenian"; +"enum.language.value.somali" = "Somali"; +"enum.language.value.spanish" = "Spanish"; +"enum.language.value.swahili" = "Swahili"; +"enum.language.value.swedish" = "Swedish"; +"enum.language.value.tagalog" = "Tagalog"; +"enum.language.value.thai" = "Thai"; +"enum.language.value.tigrinya" = "Tigrinya"; +"enum.language.value.turkish" = "Turkish"; +"enum.language.value.ukrainian" = "Ukrainian"; +"enum.language.value.urdu" = "Urdu"; +"enum.language.value.vietnamese" = "Vietnamese"; +"enum.language.value.zulu" = "Zulu"; + +// MARK: BrowsingCountry +"enum.browsing.country.name.autoDetect" = "Auto-Detect"; +"enum.browsing.country.name.afghanistan" = "Afghanistan"; +"enum.browsing.country.name.alandIslands" = "Aland Islands"; +"enum.browsing.country.name.albania" = "Albania"; +"enum.browsing.country.name.algeria" = "Algeria"; +"enum.browsing.country.name.americanSamoa" = "American Samoa"; +"enum.browsing.country.name.andorra" = "Andorra"; +"enum.browsing.country.name.angola" = "Angola"; +"enum.browsing.country.name.anguilla" = "Anguilla"; +"enum.browsing.country.name.antarctica" = "Antarctica"; +"enum.browsing.country.name.antiguaAndBarbuda" = "Antigua and Barbuda"; +"enum.browsing.country.name.argentina" = "Argentina"; +"enum.browsing.country.name.armenia" = "Armenia"; +"enum.browsing.country.name.aruba" = "Aruba"; +"enum.browsing.country.name.asiaPacificRegion" = "Asia-Pacific Region"; +"enum.browsing.country.name.australia" = "Australia"; +"enum.browsing.country.name.austria" = "Austria"; +"enum.browsing.country.name.azerbaijan" = "Azerbaijan"; +"enum.browsing.country.name.bahamas" = "Bahamas"; +"enum.browsing.country.name.bahrain" = "Bahrain"; +"enum.browsing.country.name.bangladesh" = "Bangladesh"; +"enum.browsing.country.name.barbados" = "Barbados"; +"enum.browsing.country.name.belarus" = "Belarus"; +"enum.browsing.country.name.belgium" = "Belgium"; +"enum.browsing.country.name.belize" = "Belize"; +"enum.browsing.country.name.benin" = "Benin"; +"enum.browsing.country.name.bermuda" = "Bermuda"; +"enum.browsing.country.name.bhutan" = "Bhutan"; +"enum.browsing.country.name.bolivia" = "Bolivia"; +"enum.browsing.country.name.bonaireSaintEustatiusAndSaba" = "Bonaire Saint Eustatius and Saba"; +"enum.browsing.country.name.bosniaAndHerzegovina" = "Bosnia and Herzegovina"; +"enum.browsing.country.name.botswana" = "Botswana"; +"enum.browsing.country.name.bouvetIsland" = "Bouvet Island"; +"enum.browsing.country.name.brazil" = "Brazil"; +"enum.browsing.country.name.britishIndianOceanTerritory" = "British Indian Ocean Territory"; +"enum.browsing.country.name.bruneiDarussalam" = "Brunei Darussalam"; +"enum.browsing.country.name.bulgaria" = "Bulgaria"; +"enum.browsing.country.name.burkinaFaso" = "Burkina Faso"; +"enum.browsing.country.name.burundi" = "Burundi"; +"enum.browsing.country.name.cambodia" = "Cambodia"; +"enum.browsing.country.name.cameroon" = "Cameroon"; +"enum.browsing.country.name.canada" = "Canada"; +"enum.browsing.country.name.capeVerde" = "Cape Verde"; +"enum.browsing.country.name.caymanIslands" = "Cayman Islands"; +"enum.browsing.country.name.centralAfricanRepublic" = "Central African Republic"; +"enum.browsing.country.name.chad" = "Chad"; +"enum.browsing.country.name.chile" = "Chile"; +"enum.browsing.country.name.china" = "China"; +"enum.browsing.country.name.christmasIsland" = "Christmas Island"; +"enum.browsing.country.name.cocosIslands" = "Cocos Islands"; +"enum.browsing.country.name.colombia" = "Colombia"; +"enum.browsing.country.name.comoros" = "Comoros"; +"enum.browsing.country.name.congo" = "Congo"; +"enum.browsing.country.name.theDemocraticRepublicOfTheCongo" = "The Democratic Republic of the Congo"; +"enum.browsing.country.name.cookIslands" = "Cook Islands"; +"enum.browsing.country.name.costaRica" = "Costa Rica"; +"enum.browsing.country.name.coteDIvoire" = "Cote D'Ivoire"; +"enum.browsing.country.name.croatia" = "Croatia"; +"enum.browsing.country.name.cuba" = "Cuba"; +"enum.browsing.country.name.curacao" = "Curacao"; +"enum.browsing.country.name.cyprus" = "Cyprus"; +"enum.browsing.country.name.czechRepublic" = "Czech Republic"; +"enum.browsing.country.name.denmark" = "Denmark"; +"enum.browsing.country.name.djibouti" = "Djibouti"; +"enum.browsing.country.name.dominica" = "Dominica"; +"enum.browsing.country.name.dominicanRepublic" = "Dominican Republic"; +"enum.browsing.country.name.ecuador" = "Ecuador"; +"enum.browsing.country.name.egypt" = "Egypt"; +"enum.browsing.country.name.elSalvador" = "El Salvador"; +"enum.browsing.country.name.equatorialGuinea" = "Equatorial Guinea"; +"enum.browsing.country.name.eritrea" = "Eritrea"; +"enum.browsing.country.name.estonia" = "Estonia"; +"enum.browsing.country.name.ethiopia" = "Ethiopia"; +"enum.browsing.country.name.europe" = "Europe"; +"enum.browsing.country.name.falklandIslands" = "Falkland Islands"; +"enum.browsing.country.name.faroeIslands" = "Faroe Islands"; +"enum.browsing.country.name.fiji" = "Fiji"; +"enum.browsing.country.name.finland" = "Finland"; +"enum.browsing.country.name.france" = "France"; +"enum.browsing.country.name.frenchGuiana" = "French Guiana"; +"enum.browsing.country.name.frenchPolynesia" = "French Polynesia"; +"enum.browsing.country.name.frenchSouthernTerritories" = "French Southern Territories"; +"enum.browsing.country.name.gabon" = "Gabon"; +"enum.browsing.country.name.gambia" = "Gambia"; +"enum.browsing.country.name.georgia" = "Georgia"; +"enum.browsing.country.name.germany" = "Germany"; +"enum.browsing.country.name.ghana" = "Ghana"; +"enum.browsing.country.name.gibraltar" = "Gibraltar"; +"enum.browsing.country.name.greece" = "Greece"; +"enum.browsing.country.name.greenland" = "Greenland"; +"enum.browsing.country.name.grenada" = "Grenada"; +"enum.browsing.country.name.guadeloupe" = "Guadeloupe"; +"enum.browsing.country.name.guam" = "Guam"; +"enum.browsing.country.name.guatemala" = "Guatemala"; +"enum.browsing.country.name.guernsey" = "Guernsey"; +"enum.browsing.country.name.guinea" = "Guinea"; +"enum.browsing.country.name.guineaBissau" = "Guinea-Bissau"; +"enum.browsing.country.name.guyana" = "Guyana"; +"enum.browsing.country.name.haiti" = "Haiti"; +"enum.browsing.country.name.heardIslandAndMcDonaldIslands" = "Heard Island and McDonald Islands"; +"enum.browsing.country.name.vaticanCityState" = "Vatican City State"; +"enum.browsing.country.name.honduras" = "Honduras"; +"enum.browsing.country.name.hongKong" = "Hong Kong"; +"enum.browsing.country.name.hungary" = "Hungary"; +"enum.browsing.country.name.iceland" = "Iceland"; +"enum.browsing.country.name.india" = "India"; +"enum.browsing.country.name.indonesia" = "Indonesia"; +"enum.browsing.country.name.iran" = "Iran"; +"enum.browsing.country.name.iraq" = "Iraq"; +"enum.browsing.country.name.ireland" = "Ireland"; +"enum.browsing.country.name.isleOfMan" = "Isle of Man"; +"enum.browsing.country.name.israel" = "Israel"; +"enum.browsing.country.name.italy" = "Italy"; +"enum.browsing.country.name.jamaica" = "Jamaica"; +"enum.browsing.country.name.japan" = "Japan"; +"enum.browsing.country.name.jersey" = "Jersey"; +"enum.browsing.country.name.jordan" = "Jordan"; +"enum.browsing.country.name.kazakhstan" = "Kazakhstan"; +"enum.browsing.country.name.kenya" = "Kenya"; +"enum.browsing.country.name.kiribati" = "Kiribati"; +"enum.browsing.country.name.kuwait" = "Kuwait"; +"enum.browsing.country.name.kyrgyzstan" = "Kyrgyzstan"; +"enum.browsing.country.name.laoPeoplesDemocraticRepublic" = "Lao People's Democratic Republic"; +"enum.browsing.country.name.latvia" = "Latvia"; +"enum.browsing.country.name.lebanon" = "Lebanon"; +"enum.browsing.country.name.lesotho" = "Lesotho"; +"enum.browsing.country.name.liberia" = "Liberia"; +"enum.browsing.country.name.libya" = "Libya"; +"enum.browsing.country.name.liechtenstein" = "Liechtenstein"; +"enum.browsing.country.name.lithuania" = "Lithuania"; +"enum.browsing.country.name.luxembourg" = "Luxembourg"; +"enum.browsing.country.name.macau" = "Macau"; +"enum.browsing.country.name.macedonia" = "Macedonia"; +"enum.browsing.country.name.madagascar" = "Madagascar"; +"enum.browsing.country.name.malawi" = "Malawi"; +"enum.browsing.country.name.malaysia" = "Malaysia"; +"enum.browsing.country.name.maldives" = "Maldives"; +"enum.browsing.country.name.mali" = "Mali"; +"enum.browsing.country.name.malta" = "Malta"; +"enum.browsing.country.name.marshallIslands" = "Marshall Islands"; +"enum.browsing.country.name.martinique" = "Martinique"; +"enum.browsing.country.name.mauritania" = "Mauritania"; +"enum.browsing.country.name.mauritius" = "Mauritius"; +"enum.browsing.country.name.mayotte" = "Mayotte"; +"enum.browsing.country.name.mexico" = "Mexico"; +"enum.browsing.country.name.micronesia" = "Micronesia"; +"enum.browsing.country.name.moldova" = "Moldova"; +"enum.browsing.country.name.monaco" = "Monaco"; +"enum.browsing.country.name.mongolia" = "Mongolia"; +"enum.browsing.country.name.montenegro" = "Montenegro"; +"enum.browsing.country.name.montserrat" = "Montserrat"; +"enum.browsing.country.name.morocco" = "Morocco"; +"enum.browsing.country.name.mozambique" = "Mozambique"; +"enum.browsing.country.name.myanmar" = "Myanmar"; +"enum.browsing.country.name.namibia" = "Namibia"; +"enum.browsing.country.name.nauru" = "Nauru"; +"enum.browsing.country.name.nepal" = "Nepal"; +"enum.browsing.country.name.netherlands" = "Netherlands"; +"enum.browsing.country.name.newCaledonia" = "New Caledonia"; +"enum.browsing.country.name.newZealand" = "New Zealand"; +"enum.browsing.country.name.nicaragua" = "Nicaragua"; +"enum.browsing.country.name.niger" = "Niger"; +"enum.browsing.country.name.nigeria" = "Nigeria"; +"enum.browsing.country.name.niue" = "Niue"; +"enum.browsing.country.name.norfolkIsland" = "Norfolk Island"; +"enum.browsing.country.name.northKorea" = "North Korea"; +"enum.browsing.country.name.northernMarianaIslands" = "Northern Mariana Islands"; +"enum.browsing.country.name.norway" = "Norway"; +"enum.browsing.country.name.oman" = "Oman"; +"enum.browsing.country.name.pakistan" = "Pakistan"; +"enum.browsing.country.name.palau" = "Palau"; +"enum.browsing.country.name.palestinianTerritory" = "Palestinian Territory"; +"enum.browsing.country.name.panama" = "Panama"; +"enum.browsing.country.name.papuaNewGuinea" = "Papua New Guinea"; +"enum.browsing.country.name.paraguay" = "Paraguay"; +"enum.browsing.country.name.peru" = "Peru"; +"enum.browsing.country.name.philippines" = "Philippines"; +"enum.browsing.country.name.pitcairnIslands" = "Pitcairn Islands"; +"enum.browsing.country.name.poland" = "Poland"; +"enum.browsing.country.name.portugal" = "Portugal"; +"enum.browsing.country.name.puertoRico" = "Puerto Rico"; +"enum.browsing.country.name.qatar" = "Qatar"; +"enum.browsing.country.name.reunion" = "Reunion"; +"enum.browsing.country.name.romania" = "Romania"; +"enum.browsing.country.name.russianFederation" = "Russian Federation"; +"enum.browsing.country.name.rwanda" = "Rwanda"; +"enum.browsing.country.name.saintBarthelemy" = "Saint Barthelemy"; +"enum.browsing.country.name.saintHelena" = "Saint Helena"; +"enum.browsing.country.name.saintKittsAndNevis" = "Saint Kitts and Nevis"; +"enum.browsing.country.name.saintLucia" = "Saint Lucia"; +"enum.browsing.country.name.saintMartin" = "Saint Martin"; +"enum.browsing.country.name.saintPierreAndMiquelon" = "Saint Pierre and Miquelon"; +"enum.browsing.country.name.saintVincentAndTheGrenadines" = "Saint Vincent and the Grenadines"; +"enum.browsing.country.name.samoa" = "Samoa"; +"enum.browsing.country.name.sanMarino" = "San Marino"; +"enum.browsing.country.name.saoTomeAndPrincipe" = "Sao Tome and Principe"; +"enum.browsing.country.name.saudiArabia" = "Saudi Arabia"; +"enum.browsing.country.name.senegal" = "Senegal"; +"enum.browsing.country.name.serbia" = "Serbia"; +"enum.browsing.country.name.seychelles" = "Seychelles"; +"enum.browsing.country.name.sierraLeone" = "Sierra Leone"; +"enum.browsing.country.name.singapore" = "Singapore"; +"enum.browsing.country.name.sintMaarten" = "Sint Maarten"; +"enum.browsing.country.name.slovakia" = "Slovakia"; +"enum.browsing.country.name.slovenia" = "Slovenia"; +"enum.browsing.country.name.solomonIslands" = "Solomon Islands"; +"enum.browsing.country.name.somalia" = "Somalia"; +"enum.browsing.country.name.southAfrica" = "South Africa"; +"enum.browsing.country.name.southGeorgiaAndTheSouthSandwichIslands" = "South Georgia and the South Sandwich Islands"; +"enum.browsing.country.name.southKorea" = "South Korea"; +"enum.browsing.country.name.southSudan" = "South Sudan"; +"enum.browsing.country.name.spain" = "Spain"; +"enum.browsing.country.name.sriLanka" = "Sri Lanka"; +"enum.browsing.country.name.sudan" = "Sudan"; +"enum.browsing.country.name.suriname" = "Suriname"; +"enum.browsing.country.name.svalbardAndJanMayen" = "Svalbard and Jan Mayen"; +"enum.browsing.country.name.swaziland" = "Swaziland"; +"enum.browsing.country.name.sweden" = "Sweden"; +"enum.browsing.country.name.switzerland" = "Switzerland"; +"enum.browsing.country.name.syrianArabRepublic" = "Syrian Arab Republic"; +"enum.browsing.country.name.taiwan" = "Taiwan"; +"enum.browsing.country.name.tajikistan" = "Tajikistan"; +"enum.browsing.country.name.tanzania" = "Tanzania"; +"enum.browsing.country.name.thailand" = "Thailand"; +"enum.browsing.country.name.timorLeste" = "Timor-Leste"; +"enum.browsing.country.name.togo" = "Togo"; +"enum.browsing.country.name.tokelau" = "Tokelau"; +"enum.browsing.country.name.tonga" = "Tonga"; +"enum.browsing.country.name.trinidadAndTobago" = "Trinidad and Tobago"; +"enum.browsing.country.name.tunisia" = "Tunisia"; +"enum.browsing.country.name.turkey" = "Turkey"; +"enum.browsing.country.name.turkmenistan" = "Turkmenistan"; +"enum.browsing.country.name.turksAndCaicosIslands" = "Turks and Caicos Islands"; +"enum.browsing.country.name.tuvalu" = "Tuvalu"; +"enum.browsing.country.name.uganda" = "Uganda"; +"enum.browsing.country.name.ukraine" = "Ukraine"; +"enum.browsing.country.name.unitedArabEmirates" = "United Arab Emirates"; +"enum.browsing.country.name.unitedKingdom" = "United Kingdom"; +"enum.browsing.country.name.unitedStates" = "United States"; +"enum.browsing.country.name.unitedStatesMinorOutlyingIslands" = "United States Minor Outlying Islands"; +"enum.browsing.country.name.uruguay" = "Uruguay"; +"enum.browsing.country.name.uzbekistan" = "Uzbekistan"; +"enum.browsing.country.name.vanuatu" = "Vanuatu"; +"enum.browsing.country.name.venezuela" = "Venezuela"; +"enum.browsing.country.name.vietnam" = "Vietnam"; +"enum.browsing.country.name.virginIslandsBritish" = "British Virgin Islands"; +"enum.browsing.country.name.virginIslandsUS" = "U.S. Virgin Islands"; +"enum.browsing.country.name.wallisAndFutuna" = "Wallis and Futuna"; +"enum.browsing.country.name.westernSahara" = "Western Sahara"; +"enum.browsing.country.name.yemen" = "Yemen"; +"enum.browsing.country.name.zambia" = "Zambia"; +"enum.browsing.country.name.zimbabwe" = "Zimbabwe"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index ab001b9c..73035bd4 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -1,474 +1,878 @@ -/* +/* Localizable.strings EhPanda Created by 荒木辰造 on R 2/12/25. - + */ +// MARK: BanInterval +"enum.ban.interval.description.and" = ""; + +// MARK: ToplistsType +"enum.toplists.type.value.yesterday" = "昨日"; +"enum.toplists.type.value.pastMonth" = "先月"; +"enum.toplists.type.value.pastYear" = "去年"; +"enum.toplists.type.value.allTime" = "すべて"; + // MARK: Response -"You must have a H@H client assigned to your account to use this feature." = "この機能を使うには、アカウントに関連付けられているHathクライアントが必要です"; -"Your H@H client appears to be offline. Turn it on, then try again." = "Hath クライアントは現在オフラインのようです、起動してからもう一度お試しください"; -"The requested gallery cannot be downloaded with the selected resolution." = "このギャラリーは選択された解像度ではダウンロードできません"; +"hath.download.response.hathClientNotFound" = "この機能を使うには、アカウントに関連付けられている H@H クライアントが必要です"; +"hath.download.response.hathClientNotOnline" = "H@H クライアントは現在オフラインのようです、起動してからもう一度お試しください"; +"hath.download.response.invalidResolution" = "このギャラリーは選択された解像度ではダウンロードできません"; // MARK: HUD -"Success" = "成功"; -"Error" = "エラー"; -"Communicating..." = "サーバーと通信中..."; -"Copied to clipboard" = "クリップボードにコピーしました"; +"hud.title.error" = "エラー"; +"hud.title.success" = "成功"; +"hud.title.loading" = "読み込み中..."; +"hud.title.communicating" = "通信中..."; +"hud.caption.copiedToClipboard" = "クリップボードにコピーしました"; +"hud.caption.savedToPhotoLibrary" = "ライブラリに保存しました"; + +// MARK: AutoLock +"local.authorization.reason" = "自動ロック期限が切れたため、アプリがロックされています"; + +// MARK: Common value +"common.value.stars" = "%@ つ星"; +"common.value.pages" = "%@ ページ"; +"common.value.times" = "%@ 回"; +"common.value.day" = "%@ 日"; +"common.value.days" = "%@ 日"; +"common.value.hour" = "%@ 時間"; +"common.value.hours" = "%@ 時間"; +"common.value.minute" = "%@ 分"; +"common.value.minutes" = "%@ 分"; +"common.value.second" = "%@ 秒"; +"common.value.seconds" = "%@ 秒"; +"common.value.records" = "%@ 件のレコード"; + +// MARK: TabItem +"tab.item.title.home" = "ホーム"; +"tab.item.title.favorites" = "お気に入り"; +"tab.item.title.search" = "検索"; +"tab.item.title.setting" = "設定"; + +// MARK: ToolbarItem +"toolbar.item.button.filters" = "フィルター"; +"toolbar.item.button.jumpPage" = "ページジャンプ"; +"toolbar.item.button.quickSearch" = "クイック検索"; + +// MARK: JumpPage +"jump.page.view.title.jumpPage" = "ページジャンプ"; +"jump.page.view.button.confirm" = "確認"; -// MARK: LockView -"The App has been locked due to the auto-lock expiration." = "自動ロック期限が切れたため、アプリがロックされています"; +// MARK: AlertView +"loading.view.title.loading" = "読み込み中..."; +"loading.view.title.preparingDatabase" = "データベース準備中..."; +"not.login.view.title.needLogin" = "本機能をご利用になるにはログインが必要です"; +"not.login.view.button.login" = "ログイン"; +"error.view.button.retry" = "やり直す"; +"error.view.button.dropDatabase" = "データベースを削除"; +"error.view.title.tryLater" = "しばらくしてからもう一度お試しください"; +"error.view.title.network" = "ネットワーク障害が発生しました"; +"error.view.title.parsing" = "解析中に問題が発生しました"; +"error.view.title.unknown" = "不明なエラーが発生しました"; +"error.view.title.notFound" = "ここには何もないようです"; +"error.view.title.databaseCorrupted" = "データベースが破損しています。\nGitHub で Issue を作成していただくようお願いいたします。"; +"error.view.title.ipBanned" = "この IP アドレスを経由して過剰なページロードが行われました。クローラの疑いがあるため、この IP アドレスは一時的にブロックされました。ブロックは %@後に解除されます。"; +"error.view.title.copyrightClaim" = "申し訳ありませんが、このギャラリーは %@ の著作権主張によってアクセス不可になっています。"; +"error.view.title.galleryUnavailable" = "このギャラリーはすでに削除済みまたは無効です。"; + +// MARK: ConfirmationDialog +"confirmation.dialog.title.dropDatabase" = "本アプリでのすべてのデータを失うことになります。\n本当にデータベースを削除してもよろしいですか?"; +"confirmation.dialog.title.removeCustomTranslations" = "本当にカスタム翻訳を削除してもよろしいですか?"; +"confirmation.dialog.title.logout" = "本当にログアウトしてもよろしいですか?"; +"confirmation.dialog.title.delete" = "本当にこれを削除してもよろしいですか?"; +"confirmation.dialog.title.clear" = "本当に削除してもよろしいですか?"; +"confirmation.dialog.title.reset" = "本当に戻してもよろしいですか?"; +"confirmation.dialog.button.dropDatabase" = "データベースを削除"; +"confirmation.dialog.button.remove" = "削除"; +"confirmation.dialog.button.logout" = "ログアウト"; +"confirmation.dialog.button.delete" = "削除"; +"confirmation.dialog.button.clear" = "削除"; +"confirmation.dialog.button.reset" = "戻す"; + +// MARK: SubSection +"sub.section.button.showAll" = "すべて表示"; -// MARK: Common -"null" = "なし"; -"expired" = "期限切れ"; -"mystery" = "拒否"; +// MARK: NewDawnView +"new.dawn.view.title.first" = "新しい一日の夜明けです!"; +"new.dawn.view.title.second" = "今までの歩みを振り返り、少し賢くなった気がする。"; +// Greeting +"struct.greeting.mark.start" = ""; +"struct.greeting.mark.separator" = "、"; +"struct.greeting.mark.and" = " と "; +"struct.greeting.mark.end" = "を手に入れた!"; -// MARK: User -"favoriteNameByDev" = "お気に入り"; -"all_appendedByDev" = "すべて"; +// MARK: HomeView +"home.view.title.home" = "ホーム"; +"home.view.section.title.frontpage" = "フロントページ"; +"home.view.section.title.toplists" = "ランキング"; +"home.view.section.title.other" = "その他"; +// HomeMiscGridType +"home.misc.grid.type.title.popular" = "人気"; +"home.misc.grid.type.title.watched" = "タグの購読"; +"home.misc.grid.type.title.history" = "閲覧履歴"; + +// MARK: FrontpageView +"frontpage.view.title.frontpage" = "フロントページ"; + +// MARK: ToplistsView +"toplists.view.title.toplists" = "ランキング"; + +// MARK: PopularView +"popular.view.title.popular" = "人気"; + +// MARK: WatchedView +"watched.view.title.watched" = "タグの購読"; + +// MARK: HistoryView +"history.view.title.history" = "閲覧履歴"; + +// MARK: FavoritesView +"favorites.view.title.favorites" = "お気に入り"; +// FavoriteCategory +"favorite.category.default" = "お気に入り %@"; +"favorite.category.all" = "すべて"; + +// MARK: SearchView +"search.view.title.search" = "検索"; +"search.view.section.title.recentlySearched" = "最近検索した項目"; +"search.view.section.title.recentlySeen" = "最近閲覧した項目"; +"search.view.section.title.quickSearch" = "クイック検索"; +// Searchable prompt +"searchable.prompt.filter" = "フィルター"; -// MARK: AlertView -"Loading..." = "読み込み中..."; -"Login" = "ログイン"; -"There seems to be nothing here." = "ここには何もないようです"; -"Retry" = "やり直す"; -"A network error occurred." = "ネットワーク障害が発生しました"; -"A parsing error occurred." = "解析中に問題が発生しました"; -"An unknown error occurred." = "不明なエラーが発生しました"; -"Please try again later." = "しばらくしてからもう一度お試しください"; -"This gallery has been removed or is unavailable." = "このギャラリーはすでに削除済みまたは無効です。"; -"This gallery is unavailable due to a copyright claim by PLACEHOLDER. Sorry about that." = "申し訳ありませんが、このギャラリーは PLACEHOLDER の著作権主張によってアクセス不可になっています。"; -"Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software." = "この IP アドレスを経由して過剰なページロードが行われました。クローラの疑いがあるため、この IP アドレスは一時的にブロックされました。"; -"The ban expires in PLACEHOLDER." = "ブロックは PLACEHOLDER 後に解除されます。"; -"BAN_INTERVAL_AND" = " "; -"BAN_INTERVAL_DAYS" = " 日"; -"BAN_INTERVAL_HOURS" = " 時間"; -"BAN_INTERVAL_MINUTES" = " 分"; -"BAN_INTERVAL_SECONDS" = " 秒"; -"Jump page" = "ページジャンプ"; -"Confirm" = "確認"; +// MARK: QuickSearchView +"quick.search.view.title.quickSearch" = "クイック検索"; +"quick.search.view.title.editWord" = "キーワードを編集"; +"quick.search.view.title.newWord" = "キーワードを追加"; +"quick.search.view.title.content" = "内容"; +"quick.search.view.title.name" = "名前"; +"quick.search.view.placeholder.optional" = "任意"; +"quick.search.view.toolbar.item.button.confirm" = "確認"; -// MARK: HomeView -"Clear history" = "履歴を削除"; +// MARK: SettingView +"setting.view.title.setting" = "設定"; +// SettingStateRoute +"enum.setting.state.route.value.account" = "アカウント"; +"enum.setting.state.route.value.general" = "一般"; +"enum.setting.state.route.value.appearance" = "外観"; +"enum.setting.state.route.value.reading" = "閲覧"; +"enum.setting.state.route.value.laboratory" = "ラボ"; +"enum.setting.state.route.value.ehPanda" = "EhPanda について"; + +// MARK: AccountSettingView +"account.setting.view.title.account" = "アカウント"; +"account.setting.view.title.showsNewDawnGreeting" = "夜明けの挨拶を表示"; +"account.setting.view.button.login" = "ログイン"; +"account.setting.view.button.logout" = "ログアウト"; +"account.setting.view.button.accountConfiguration" = "アカウント設定"; +"account.setting.view.button.tagsManagement" = "タグの購読を管理"; +"account.setting.view.button.copyCookies" = "クッキーをコピー"; +// CookieValue +"struct.cookie.value.localized.string.expired" = "期限切れ"; +"struct.cookie.value.localized.string.mystery" = "拒否"; +"struct.cookie.value.localized.string.none" = "なし"; + +// MARK: LoginView +"login.view.title.login" = "ログイン"; +"login.view.title.username" = "ユーザー名"; +"login.view.title.password" = "パスワード"; + +// MARK: GeneralSettingView +"general.setting.view.title.general" = "一般"; +"general.setting.view.title.language" = "言語"; +"general.setting.view.title.autoLock" = "自動ロック"; +"general.setting.view.title.translatesTags" = "タグを訳す"; +"general.setting.view.title.redirectsLinksToTheSelectedHost" = "リンクを選択されたホストへリダイレクト"; +"general.setting.view.title.detectsLinksFromClipboard" = "クリップボードからリンクを探知"; +"general.setting.view.title.backgroundBlurRadius" = "バッググラウンドぼかし度"; +"general.setting.view.button.logs" = "ログ"; +"general.setting.view.button.importCustomTranslations" = "カスタム翻訳を取り込む"; +"general.setting.view.button.removeCustomTranslations" = "カスタム翻訳を削除"; +"general.setting.view.button.clearImageCaches" = "画像キャッシュを削除"; +"general.setting.view.value.defaultLanguageDescription" = "無効"; +"general.setting.view.section.title.tagsTranslation" = "タグの翻訳"; +"general.setting.view.section.title.navigation" = "ナビゲーション"; +"general.setting.view.section.title.security" = "セキュリティ"; +"general.setting.view.section.title.caches" = "キャッシュ"; +// AutoLockPolicy +"enum.auto.lock.policy.value.never" = "なし"; +"enum.auto.lock.policy.value.instantly" = "すぐに"; + +// MARK: LogsView +"logs.view.title.logs" = "ログ"; +"logs.view.title.latest" = "最新"; + +// MARK: AppearanceSettingView +"appearance.setting.view.title.appearance" = "外観"; +"appearance.setting.view.title.theme" = "テーマ"; +"appearance.setting.view.title.tintColor" = "テーマの色"; +"appearance.setting.view.title.displayMode" = "表示モード"; +"appearance.setting.view.title.showsTagsInList" = "リストでタグを表示"; +"appearance.setting.view.title.maximumNumberOfTags" = "タグ数上限"; +"appearance.setting.view.button.appIcon" = "アプリアイコン"; +"appearance.setting.view.menu.title.infite" = "無制限"; +"appearance.setting.view.section.title.list" = "リスト"; +// PerferredColorScheme +"enum.perferred.color.scheme.value.automatic" = "自動"; +"enum.perferred.color.scheme.value.light" = "ライト"; +"enum.perferred.color.scheme.value.dark" = "ダーク"; +// AppIconType +"enum.app.icon.type.value.default" = "デフォルト"; +"enum.app.icon.type.value.ukiyoe" = "浮世絵"; +// ListDisplayMode +"enum.display.mode.value.detail" = "デフォルト"; +"enum.display.mode.value.thumbnail" = "サムネイル"; + +// MARK: AppIconView +"app.icon.view.title.appIcon" = "アプリアイコン"; + +// MARK: ReadingSettingView +"reading.setting.view.title.reading" = "閲覧"; +"reading.setting.view.title.direction" = "方向"; +"reading.setting.view.title.preloadLimit" = "プリロード上限数"; +"reading.setting.view.title.enablesLandscape" = "横向きを有効"; +"reading.setting.view.title.separatorHeight" = "仕切りの高さ"; +"reading.setting.view.title.maximumScaleFactor" = "最大スケール係数"; +"reading.setting.view.title.doubleTapScaleFactor" = "ダブルタップスケール係数"; +"reading.setting.view.section.title.appearance" = "外観"; +// ReadingDirection +"enum.reading.direction.value.vertical" = "縦読み"; +"enum.reading.direction.value.rightToLeft" = "右開き"; +"enum.reading.direction.value.leftToRight" = "左開き"; + +// MARK: LaboratorySettingView +"laboratory.setting.view.title.laboratory" = "ラボ"; +"laboratory.setting.view.title.bypassesSNIFiltering" = "SNI フィルタリング回避"; + +// MARK: EhPandaView +"ehpanda.view.title.ehPanda" = "EhPanda"; +"ehpanda.view.button.website" = "ウェブサイト"; +"ehpanda.view.button.altStoreSource" = "AltStore ソース"; +"ehpanda.view.description.version" = "バージョン"; +"ehpanda.view.section.title.specialThanks" = "特別な感謝"; +"ehpanda.view.section.title.codeLevelContributors" = "コードレベル貢献者"; +"ehpanda.view.section.title.translationContributors" = "翻訳貢献者"; +"ehpanda.view.section.title.acknowledgements" = "謝辞"; // MARK: DetailView -"Archive" = "アーカイブ"; -"Torrents" = "トレント"; -"Share" = "共有"; -"Read" = "読む"; -"DESC_SCROLL_ITEM_FAVORITED" = "気に入り"; -"Times" = "人"; -"Language" = "言語"; -"%lld Ratings" = "%lld 件の評価"; -"Page Count" = "ページ数"; -"Pages" = "ページ"; -"File Size" = "ファイルサイズ"; -"Give a Rating" = "評価する"; -"Similar Gallery" = "類似ギャラリー"; -"Preview" = "プレビュー"; -"Comment" = "コメント"; -"Show All" = "すべて表示"; - -// MARK: ArchiveView -"N/A" = "無効"; -"Free" = "無料"; -"ARCHIVE_RESOLUTION_ORIGINAL" = "オリジナル"; -"Download To Hath Client" = "Hath クライアントにダウンロード"; +"detail.view.button.read" = "閲覧"; +"detail.view.button.postComment" = "コメントを書く"; +"detail.view.toolbar.item.button.archives" = "アーカイブ"; +"detail.view.toolbar.item.button.torrents" = "トレント"; +"detail.view.toolbar.item.button.share" = "共有"; +"detail.view.scroll.section.title.favorited" = "気に入り"; +"detail.view.scroll.section.title.language" = "言語"; +"detail.view.scroll.section.title.ratings" = "%@ 件の評価"; +"detail.view.scroll.section.title.pageCount" = "ページ数"; +"detail.view.scroll.section.title.fileSize" = "ファイルサイズ"; +"detail.view.scroll.section.description.favorited" = "回"; +"detail.view.scroll.section.description.pageCount" = "ページ"; +"detail.view.action.section.button.giveARating" = "評価する"; +"detail.view.action.section.button.similarGallery" = "類似ギャラリー"; +"detail.view.section.title.previews" = "プレビュー"; +"detail.view.section.title.comments" = "コメント"; + +// MARK: ArchivesView +"archives.view.title.archives" = "アーカイブ"; +"archives.view.button.downloadToHathClient" = "H@H クライアントにダウンロード"; +// HathArchive +"struct.hath.archive.price.value.free" = "無料"; +"struct.hath.archive.price.value.notAvailable" = "無効"; +"struct.hath.archive.resolution.value.original" = "オリジナル"; + +// MARK: TorrentsView +"torrents.view.title.torrents" = "トレント"; // MARK: GalleryInfosView -"Gallery infos" = "ギャラリー情報"; -"Title" = "タイトル"; -"Japanese title" = "日本語タイトル"; -"Gallery URL" = "ギャラリーリンク"; -"Cover URL" = "カバーリンク"; -"Archive URL" = "アーカイブリンク"; -"Torrent URL" = "トレントリンク"; -"Parent URL" = "親ギャラリーリンク"; -"Category" = "カテゴリー"; -"Uploader" = "アップローダー"; -"Posted date" = "投稿日付"; -"Visible" = "可視"; -"Page count" = "ページ数"; -"File size" = "ファイルサイズ"; -"Favorited times" = "気に入り数"; -"Favorited" = "お気に入り済み"; -"Rating count" = "評価数"; -"Average rating" = "平均評価"; -"User rating" = "ユーザー評価"; -"Torrent count" = "トレント数"; -"Yes" = "はい"; -"No" = "いいえ"; -"Expunged" = "削除済み"; - -// MARK: CommentView -"Post Comment" = "コメントを書く"; -"Edit Comment" = "コメントを編集"; -"Cancel" = "キャンセル"; -"Post" = "投稿"; +"gallery.infos.view.title.galleryInfos" = "ギャラリー情報"; +"gallery.infos.view.title.ID" = "ID"; +"gallery.infos.view.title.token" = "Token"; +"gallery.infos.view.title.title" = "タイトル"; +"gallery.infos.view.title.japaneseTitle" = "日本語タイトル"; +"gallery.infos.view.title.galleryURL" = "ギャラリーリンク"; +"gallery.infos.view.title.coverURL" = "カバーリンク"; +"gallery.infos.view.title.archiveURL" = "アーカイブリンク"; +"gallery.infos.view.title.torrentURL" = "トレントリンク"; +"gallery.infos.view.title.parentURL" = "親ギャラリーリンク"; +"gallery.infos.view.title.category" = "カテゴリー"; +"gallery.infos.view.title.uploader" = "アップローダー"; +"gallery.infos.view.title.postedDate" = "投稿日付"; +"gallery.infos.view.title.visibility" = "可視"; +"gallery.infos.view.title.language" = "言語"; +"gallery.infos.view.title.pageCount" = "ページ数"; +"gallery.infos.view.title.fileSize" = "ファイルサイズ"; +"gallery.infos.view.title.favoritedTimes" = "気に入り数"; +"gallery.infos.view.title.favorited" = "お気に入り済み"; +"gallery.infos.view.title.ratingCount" = "評価数"; +"gallery.infos.view.title.averageRating" = "平均評価"; +"gallery.infos.view.title.myRating" = "自分の評価"; +"gallery.infos.view.title.torrentCount" = "トレント数"; +"gallery.infos.view.value.none" = "なし"; +"gallery.infos.view.value.yes" = "はい"; +"gallery.infos.view.value.no" = "いいえ"; +// GalleryVisibility +"gallery.visibility.value.yes" = "はい"; +"gallery.visibility.value.no" = "いいえ (%@)"; +"gallery.visibility.value.no.reason.expunged" = "削除済み"; + +// MARK: CommentsView +"comments.view.title.comments" = "コメント"; + +// MARK: PostCommentView +"post.comment.view.title.postComment" = "コメントを書く"; +"post.comment.view.title.editComment" = "コメントを編集"; +"post.comment.view.button.cancel" = "キャンセル"; +"post.comment.view.button.post" = "投稿"; + +// MARK: PreviewsView +"previews.view.title.previews" = "プレビュー"; // MARK: ReadingView -"AutoPlay" = "自動再生"; -"Reload" = "再読み込み"; -"Copy" = "コピー"; -"Save" = "保存"; -"Save original" = "オリジナルを保存"; -"Saved to photo library" = "ライブラリに保存しました"; - -// MARK: SettingView -"Setting" = "設定"; -"Account" = "アカウント"; -"Gallery" = "ギャラリー"; -"Login" = "ログイン"; -"Username" = "ユーザー名"; -"Password" = "パスワード"; -"Logout" = "ログアウト"; -"Are you sure to logout?" = "本当にログアウトしますか?"; -"Account configuration" = "アカウント設定"; -"Manage tags subscription" = "タグの購読を管理"; -"Copy cookies" = "クッキーをコピー"; - -"General" = "一般"; -"Navigation" = "ナビゲーション"; -"Redirects links to the selected host" = "リンクを選択されたホストへリダイレクト"; -"Detects links from the clipboard" = "クリップボードからリンクを探知"; -"Security" = "セキュリティ"; -"Auto-Lock" = "自動ロック"; -"App switcher blur" = "アプリスイッチャーぼかし"; -"Cache" = "キャッシュ"; -"Clear" = "削除"; -"Clear image caches" = "画像キャッシュを削除"; -"Are you sure to clear?" = "本当に削除しますか?"; - -"Appearance" = "外観"; -"Global" = "全般"; -"Theme" = "テーマ"; -"Tint Color" = "テーマの色"; -"App Icon" = "アプリアイコン"; -"Translates tags" = "タグを訳す"; -"List" = "リスト"; -"Display mode" = "表示モード"; -"LIST_DISPLAY_MODE_DETAIL" = "詳細"; -"LIST_DISPLAY_MODE_THUMBNAIL" = "サムネイル"; -"Shows tags in list" = "リストでタグを表示"; -"Maximum number of tags" = "タグ数上限"; -"Infinity" = "無制限"; - -"Reading" = "閲覧"; -"Direction" = "方向"; -"READING_DIRECTION_VERTICAL" = "縦読み"; -"Right-to-left" = "右開き"; -"Left-to-right" = "左開き"; -"Preload limit" = "プリロード上限数"; -"%lld pages" = "%lld ページ"; -"%lld times" = "%lld 回"; -"Prefers landscape" = "横向きを好む"; -"Separator height" = "仕切りの厚さ"; -"Maximum scale factor" = "最大スケール係数"; -"Double tap scale factor" = "ダブルタップスケール係数"; -"Dual-page mode" = "デュアルページモード"; -"Except the cover" = "カバーを除く"; - -"Laboratory" = "ラボ"; -"Bypass SNI Filtering" = "SNI フィルタリング回避"; - -"About EhPanda" = "EhPanda について"; -"Version" = "バージョン"; -"Website" = "ウェブサイト"; -"AltStore Source" = "AltStore ソース"; -"Acknowledgement" = "謝辞"; +"reading.view.context.menu.button.reload" = "再読み込み"; +"reading.view.context.menu.button.copy" = "コピー"; +"reading.view.context.menu.button.save" = "保存"; +"reading.view.context.menu.button.saveOriginal" = "オリジナルを保存"; +"reading.view.context.menu.button.share" = "共有"; +"reading.view.toolbar.item.title.autoPlay" = "自動再生"; +"reading.view.toolbar.item.title.dualPageMode" = "デュアルページモード"; +"reading.view.toolbar.item.title.exceptTheCover" = "カバーを除く"; +// AutoPlayPolicy +"enum.auto.play.policy.value.off" = "オフ"; + +// MARK: FiltersView +"filters.view.title.filters" = "フィルター"; +"filters.view.title.advancedSettings" = "高度な設定"; +"filters.view.title.searchGalleryName" = "ギャラリー名を検索"; +"filters.view.title.searchGalleryTags" = "ギャラリータグを検索"; +"filters.view.title.searchGalleryDescription" = "ギャラリー説明を検索"; +"filters.view.title.searchTorrentFilenames" = "トレントファイル名を検索"; +"filters.view.title.onlyShowGalleriesWithTorrents" = "トレントを含むもののみを表示"; +"filters.view.title.searchLowPowerTags" = "低希望タグを検索"; +"filters.view.title.searchDownvotedTags" = "低評価タグを検索"; +"filters.view.title.showExpungedGalleries" = "削除済みのギャラリーを表示"; +"filters.view.title.setMinimumRating" = "評価の下限を指定"; +"filters.view.title.minimumRating" = "評価の下限"; +"filters.view.title.setPagesRange" = "ページ数範囲を指定"; +"filters.view.title.pagesRange" = "ページ数範囲"; +"filters.view.title.disableLanguageFilter" = "言語フィルターを無効化"; +"filters.view.title.disableUploaderFilter" = "アップローダフィルターを無効化"; +"filters.view.title.disableTagsFilter" = "タグフィルターを無効化"; +"filters.view.button.resetFilters" = "既定値に戻す"; +"filters.view.section.title.advanced" = "高度"; +"filters.view.section.title.defaultFilter" = "既定フィルター"; +// FilterRange +"enum.filter.range.value.search" = "検索"; +"enum.filter.range.value.global" = "全般"; +"enum.filter.range.value.watched" = "タグの購読"; // MARK: EhSettingView -"Profile Settings" = "プロファイル設定"; -"Selected profile" = "選択されたプロファイル"; -"Set as default" = "デフォルトに設定"; -"Delete profile" = "プロファイルを削除"; -"Are you sure to delete this profile?" = "本当にこのプロファイルを削除しますか?"; -"Delete" = "削除"; -"Rename" = "名前を変更"; -"Create new" = "新規作成"; - -"Image Load Settings" = "画像読み込み設定"; -"Recommended." = "推奨。"; -"Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports." = "遅くなることがあります。非標準発信ポートがファイヤーウォール・プロキシにブロックされた場合のみ有効にしてください。"; -"Donator only. You will not be able to browse as many pages, enable only if having severe problems." = "寄付者独占オプション。閲覧による割当額の消耗は激しくなります、厳重な問題が起こった場合のみ有効にしてください。"; -"Load images through the Hath network" = "Hath ネットワーク経由で画像を読み込む"; -"Any client" = "任意のクライアント"; -"Default port clients only" = "デフォルトポートのクライアントのみ"; -"LOAD_THROUGH_HATH_NO" = "使わない"; -"You appear to be browsing the site from **PLACEHOLDER** or use a VPN or proxy in this country, which means the site will try to load images from Hath clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below." = "**PLACEHOLDER** から本サイトを閲覧している、またはその国の VPN・プロキシを使用しているようです。本サイトはその地域の Hath クライアントから画像を読み込もうとしますが、もし自動検知の結果が誤っている、または特別な事情でほかの地域のクライアントを希望する場合(例えばスプリットトンネル VPN を使用している)は下に手動選択できます。"; -"Browsing country" = "閲覧国"; - -"Image Size Settings" = "画像サイズ設定"; -"Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000." = "一般的に、オンライン閲覧の画像は 1280x までにリサンプリングされます。下のいずれかのリサンプリング解像度に変更できます。サーバー負荷軽減のため、1280x 以上の解像度は現時点で寄付者、Hath Perks 利用者または UID が 3,000,000 以下の者に限定されます。"; -"Image resolution" = "画像解像度"; -"Auto" = "自動"; -"While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)" = "サイト側は画像を自動的にスクリーンに適したサイズにスケールしますが、手動的にその画像の表示サイズ最大値を指定することも可能です。ブラウザが処理を実行するため、画像のリサンプリングは行われません。(ゼロは無制限を意味します)"; -"Image size" = "画像サイズ"; -"Horizontal" = "水平"; -"Vertical" = "垂直"; - -"Gallery Name Display" = "ギャラリー名表示"; -"Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?" = "英語・ローマ字と日本語両方のタイトルを持つギャラリーはたくさんあります。どちらをデフォルトにしますか?"; -"Gallery name" = "ギャラリー名"; -"Default Title" = "デフォルトタイトル"; -"Japanese Title (if available)" = "日本語タイトル(可能なら)"; - -"Archiver Settings" = "アーカイバー設定"; -"The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here." = "アーカイバーのデフォルト操作はオリジナルとリサンプルのアーカイブのコストと選択を確認してからリンクを提供し、それからそのリンクをクリックしたりどこかにペーストしたりすることも可能です。そのデフォルト操作はここで変更できます。"; -"Archiver behavior" = "アーカイバー操作"; -"Manual Select, Manual Start (Default)" = "手動で選択、手動で開始(デフォルト)"; -"Manual Select, Auto Start" = "手動で選択、自動で開始"; -"Auto Select Original, Manual Start" = "自動でオリジナルを選択、手動で開始"; -"Auto Select Original, Auto Start" = "自動でオリジナルを選択、自動で開始"; -"Auto Select Resample, Manual Start" = "自動でリサンプルを選択、手動で開始"; -"Auto Select Resample, Auto Start" = "自動でリサンプルを選択、自動で開始"; - -"Front Page Settings" = "ホームページ設定"; -"Which display mode would you like to use on the front and search pages?" = "ホームと検索ページで使う表示モードはどれがお好みですか?"; -"Compact" = "コンパクト"; -"Thumbnail" = "サムネイル"; -"Extended" = "拡張"; -"Minimal" = "最小化"; -"Minimal+" = "最小化+"; -"What categories would you like to show by default on the front page and in searches?" = "ホームと検索ページでどれらのカテゴリーのギャラリーを表示しますか?"; - -"Here you can choose and rename your favorite categories." = "ここではお気に入りカテゴリー名の変更ができます。"; -"You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting." = "お気に入りページのデフォルト並び替え順序も変更可能です。注意:平成28年3月の改修前にお気に入りに追加した項目はタイムスタンプが含まれていないため、この設定を無視して代わりにギャラリーの投稿時間を使います。"; -"Favorites sort order" = "お気に入りの並び替え"; -"By last gallery update time" = "更新時間の新しい順"; -"By favorited time" = "気に入った時間の新しい順"; - -"Ratings" = "評価"; -"By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works." = "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。"; -"Ratings color" = "評価の色"; - -"Tag Namespaces" = "タグの名前空間"; -"If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces." = "下にある名前空間を削除仕様にすることで通常のタグ検索結果から排除できます。注意:排除された名前空間のタグを持つギャラリーが表示されなくなることはありません。"; - -"Tag Filtering Threshold" = "タグフィルタリングしきい値"; -"You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999." = "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。"; - -"Tag Watching Threshold" = "タグ購読しきい値"; -"Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999." = "もしあるギャラリーは最近投稿されたもので、少なくても一つの正の重みの購読タグを持っていて、購読タグの重み総和がこのしきい値と同じまたはより高ければ、そのギャラリーは購読画面で表示されます。このしきい値はゼロから 9999 まで設定できます。"; - -"Excluded Languages" = "排除された言語"; -"If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query." = "特定の言語のギャラリーをリストと検索結果から隠したい場合、下に選択してください。注意:どんな検索クエリーを使ってもこれらの言語のギャラリーは表示されません。"; -"Original" = "オリジナル"; -"Translated" = "翻訳版"; -"Rewrite" = "書き換え版"; - -"Excluded Uploaders" = "排除された投稿者"; -"If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query." = "特定の投稿者のギャラリーをリストと検索結果から隠したい場合、下に名前を記入してください。一行に一つのユーザー名で。注意:どんな検索クエリーを使ってもこの投稿者たちのギャラリーは表示されません。"; -"You are currently using **%lld / 1000** exclusion slots." = "現時点で **%lld / 1000** の排除スロットが使用済みです。"; - -"Search Result Count" = "検索結果数"; -"How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)" = "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\n(「Hath Perk:ページング拡張」が必要)"; -"Result count" = "結果数"; - -"Thumbnail Settings" = "サムネイル設定"; -"How would you like the mouse-over thumbnails on the front page to load when using List Mode?" = "リストでは、どんなタイミングでホームページのマウスオーバーサムネイルを読み込みますか?"; -"Pages load faster, but there may be a slight delay before a thumb appears." = "ページの読み込みが速くなりますが、サムネイルの表示はちょっぴり遅れてきます。"; -"Pages take longer to load, but there is no delay for loading a thumb after the page has loaded." = "ページの読み込み時間が増えますが、サムネイルはすぐに表示できます。"; -"Thumbnail load timing" = "サムネイル読み込みタイミング"; -"On mouse-over" = "マウス経過時"; -"On page load" = "ページ読み込み時"; -"You can set a default thumbnail configuration for all galleries you visit." = "すべてのギャラリーに適応するデフォルトのサムネイル構成を設定できます。"; -"Size" = "サイズ"; -"Large" = "大きめ"; -"Rows" = "行数"; - -"Thumbnail Scaling" = "サムネイルスケーリング"; -"Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%." = "サムネイル・拡張表示モードでのサムネイルを 75% ~ 150% のカスタム値にスケールすることができます。"; -"Scale factor" = "スケール係数"; - -"Viewport Override" = "表示領域オーバーライド"; -"Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400." = "モバイルデバイスの仮想広さをオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100% の場合、640 ~ 1400 の広さが合理的です。"; -"Virtual width" = "仮想広さ"; - -"Gallery Comments" = "ギャラリーコメント"; -"Comments sort order" = "コメントの並び替え"; -"Oldest comments first" = "コメントの古い順"; -"Recent comments first" = "コメントの新しい順"; -"By highest score" = "スコアの高い順"; -"Comment votes show timing" = "コメントスコア表示タイミング"; -"On score hover or click" = "スコアに経過・クリック時"; -"Always" = "常時"; - -"Gallery Tags" = "ギャラリータグ"; -"Tags sort order" = "タグの並び替え"; -"Alphabetical" = "アルファベット順"; -"By tag power" = "タグパワーの高い順"; - -"Gallery Page Numbering" = "ギャラリーページ数"; -"Show gallery page numbers" = "ギャラリーページ数を表示"; - -"Hath Local Network Host" = "Hathローカルネットワークホスト"; -"This setting can be used if you have a Hath client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work." = "ローカルネットワークで今と同じパブリック IP を使う Hath クライアントがお持ちの場合、この設定が役立ちます。ルーターがバグが多くてリクエストを自分の IP にルートすることができないこともあります、それをこの設定で回避できます。\nHath クライアントが今と同じデバイスで運行している場合はループバックアドレス(127.0.0.1:ポート)を使ってください。別のデバイスの場合はそのローカル IP を使ってください。かなりのブラウザの構成では外部サイトがローカル IP にアクセスすることをブロックしています、この設定を有効にするには本サイトをホワイトリストに入れてください。"; -"IP address:Port" = "IP アドレス:ポート"; - -"Original Images" = "オリジナル画像"; -"Use original images" = "オリジナル画像を使う"; -"Multi-Page Viewer" = "マルチページビューア"; -"Use Multi-Page Viewer" = "マルチページビューアを使う"; -"Display style" = "表示仕様"; -"Align left, scale if overwidth" = "左寄せ、広さによってスケール"; -"Align center, scale if overwidth" = "中央揃え、広さによってスケール"; -"Align center, always scale" = "中央揃え、常時スケール"; -"Show thumbnail pane" = "サムネイルパネルを表示"; - -// MARK: LogsView -"Logs" = "ログ"; -"Latest" = "最新"; -"%lld records" = "%lld 件のレコード"; - -// MARK: FilterView -"Filters" = "フィルター"; -"Basic" = "基本"; -"Reset filters" = "既定値に戻す"; -"Are you sure to reset?" = "本当に戻しますか?"; -"Reset" = "戻す"; -"Advanced settings" = "高度な設定"; -"Advanced" = "高度"; -"Search gallery name" = "ギャラリー名を検索"; -"Search gallery tags" = "ギャラリータグを検索"; -"Search gallery description" = "ギャラリー説明を検索"; -"Search torrent filenames" = "トレントファイル名を検索"; -"Only show galleries with torrents" = "トレントを含むもののみを表示"; -"Search Low-Power tags" = "低希望タグを検索"; -"Search downvoted tags" = "低評価タグを検索"; -"Show expunged galleries" = "削除済みのギャラリーを表示"; -"Set minimum rating" = "評価の下限を指定"; -"Minimum rating" = "評価の下限"; -"%lld stars" = "%lld つ星"; -"Set pages range" = "ページ数範囲を指定"; -"Pages range" = "ページ数範囲"; -"Default Filter" = "既定フィルター"; -"Disable language filter" = "言語フィルターを無効化"; -"Disable uploader filter" = "アップローダフィルターを無効化"; -"Disable tags filter" = "タグフィルターを無効化"; - -// MARK: NewDawnView -"Show new dawn greeting" = "夜明けの挨拶を表示"; -"It is the dawn of a new day!" = "新しい一日の夜明けです!"; -"Reflecting on your journey so far, you find that you are a little wiser." = "今までの歩みを振り返り、少し賢くなった気がする。"; -"GAINCONTENT_START" = ""; -"GAINCONTENT_SEPARATOR" = "、"; -"GAINCONTENT_AND" = "と"; -"GAINCONTENT_END" = "を手に入れた!"; - -// MARK: QuickSearchView -"Quick search" = "クイック検索"; -"Alias" = "エイリアス"; - -// MARK: HomeListType -"Search" = "検索"; -"Frontpage" = "ホーム"; -"Popular" = "人気"; -"Watched" = "タグの購読"; -"Favorites" = "お気に入り"; -"Toplists" = "ランキング"; -"Downloaded" = "ダウンロード"; -"History" = "閲覧履歴"; - -// MARK: ToplistType -"All time" = "すべて"; -"Past year" = "去年"; -"Past month" = "先月"; -"Yesterday" = "昨日"; +"eh.setting.view.title.hostSetting" = "%@ 設定"; +"eh.setting.view.section.title.profileSettings" = "プロファイル設定"; +"eh.setting.view.title.selectedProfile" = "選択されたプロファイル"; +"eh.setting.view.button.setAsDefault" = "デフォルトに設定"; +"eh.setting.view.button.deleteProfile" = "プロファイルを削除"; +"eh.setting.view.button.rename" = "名前を変更"; +"eh.setting.view.button.createNew" = "新規作成"; +"eh.setting.view.toolbar.item.button.done" = "完了"; + +"eh.setting.view.section.title.imageLoadSettings" = "画像読み込み設定"; +"eh.setting.view.title.loadImagesThroughTheHathNetwork" = "Hath ネットワーク経由で画像を読み込む"; +"eh.setting.view.title.browsingCountry" = "閲覧国"; +"eh.setting.view.description.browsingCountry" = "**%@** から本サイトを閲覧している、またはその国の VPN・プロキシを使用しているようです。本サイトはその地域の H@H クライアントから画像を読み込もうとしますが、もし自動検知の結果が誤っている、または特別な事情でほかの地域のクライアントを希望する場合(例えばスプリットトンネル VPN を使用している)は下に手動選択できます。"; +// EhSetting.LoadThroughHathSetting +"enum.eh.setting.load.through.hath.setting.value.anyClient" = "任意のクライアント"; +"enum.eh.setting.load.through.hath.setting.value.defaultPortOnly" = "デフォルトポートのクライアントのみ"; +"enum.eh.setting.load.through.hath.setting.value.modernNo" = "使わない [モダン / HTTPS]"; +"enum.eh.setting.load.through.hath.setting.value.legacyNo" = "使わない [レガシー / HTTP]"; +"enum.eh.setting.load.through.hath.setting.description.anyClient" = "推奨。"; +"enum.eh.setting.load.through.hath.setting.description.defaultPortOnly" = "遅くなることがあります。非標準発信ポートがファイヤーウォール・プロキシにブロックされた場合のみ有効にしてください。"; +"enum.eh.setting.load.through.hath.setting.description.modernNo" = "寄付者独占オプション。閲覧による割当額の消耗は激しくなります。厳重な問題が起こった場合以外おすすめしません。"; +"enum.eh.setting.load.through.hath.setting.description.legacyNo" = "寄付者独占オプション。モダンブラウザでは機能しないこともあります。レガシー・旧型ブラウザの場合以外おすすめしません。"; + +"eh.setting.view.section.title.imageSizeSettings" = "画像サイズ設定"; +"eh.setting.view.title.imageResolution" = "画像解像度"; +"eh.setting.view.description.imageResolution" = "一般的に、オンライン閲覧の画像は 1280x までにリサンプリングされます。下のいずれかのリサンプリング解像度に変更できます。サーバー負荷軽減のため、1280x 以上の解像度は現時点で寄付者、Hath Perks 利用者または UID が 3,000,000 以下の者に限定されます。"; +"eh.setting.view.title.imageSize" = "画像サイズ"; +"eh.setting.view.description.imageSize" = "サイト側は画像を自動的にスクリーンに適したサイズにスケールしますが、手動的にその画像の表示サイズ最大値を指定することも可能です。ブラウザが処理を実行するため、画像のリサンプリングは行われません。(ゼロは無制限を意味します)"; +"eh.setting.view.title.horizontal" = "幅"; +"eh.setting.view.title.vertical" = "高さ"; +// EhSetting.ImageResolution +"enum.eh.setting.image.resolution.value.auto" = "自動"; + +"eh.setting.view.section.title.galleryNameDisplay" = "ギャラリー名表示"; +"eh.setting.view.title.galleryName" = "ギャラリー名"; +"eh.setting.view.description.galleryName" = "英語・ローマ字と日本語両方のタイトルを持つギャラリーはたくさんあります。どちらをデフォルトにしますか?"; +// EhSetting.GalleryName +"enum.eh.setting.gallery.name.value.default" = "デフォルトタイトル"; +"enum.eh.setting.gallery.name.value.japanese" = "日本語タイトル(可能なら)"; + +"eh.setting.view.section.title.archiverSettings" = "アーカイバー設定"; +"eh.setting.view.title.archiverBehavior" = "アーカイバー動作"; +"eh.setting.view.description.archiverBehavior" = "アーカイバーのデフォルト動作はオリジナルとリサンプルのアーカイブのコストと選択を確認してからリンクを提供し、それからそのリンクをクリックしたりどこかにペーストしたりすることも可能です。そのデフォルト動作はここで変更できます。"; +// EhSetting.ArchiverBehavior +"enum.eh.setting.archiver.behavior.value.manualSelectManualStart" = "手動で選択、手動で開始(デフォルト)"; +"enum.eh.setting.archiver.behavior.value.manualSelectAutoStart" = "手動で選択、自動で開始"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalManualStart" = "自動でオリジナルを選択、手動で開始"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalAutoStart" = "自動でオリジナルを選択、自動で開始"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleManualStart" = "自動でリサンプルを選択、手動で開始"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleAutoStart" = "自動でリサンプルを選択、自動で開始"; + +"eh.setting.view.section.title.frontPageSettings" = "フロントページ設定"; +"eh.setting.view.title.displayMode" = "表示モード"; +"eh.setting.view.description.displayMode" = "フロント・検索ページで使う表示モードはどれにしますか?"; +"eh.setting.view.description.galleryCategory" = "フロント・検索ページでどれらのカテゴリーのギャラリーを表示しますか?"; +// EhSetting.DisplayMode +"enum.eh.setting.display.mode.value.compact" = "コンパクト"; +"enum.eh.setting.display.mode.value.thumbnail" = "サムネイル"; +"enum.eh.setting.display.mode.value.extended" = "拡張"; +"enum.eh.setting.display.mode.value.minimal" = "最小化"; +"enum.eh.setting.display.mode.value.minimalPlus" = "最小化+"; + +"eh.setting.view.section.title.favorites" = "お気に入り"; +"eh.setting.view.description.favoriteCategories" = "ここではお気に入りカテゴリー名の変更ができます。"; +"eh.setting.view.title.favoritesSortOrder" = "お気に入りの並び替え"; +"eh.setting.view.description.favoritesSortOrder" = "お気に入りページのデフォルト並び替え順序も変更可能です。注意:平成28年3月の改修前にお気に入りに追加した項目はタイムスタンプが含まれていないため、この設定を無視して代わりにギャラリーの投稿時間を使います。"; +// EhSetting.FavoritesSortOrder +"enum.eh.setting.favorites.sort.order.value.lastUpdateTime" = "更新時間の新しい順"; +"enum.eh.setting.favorites.sort.order.value.favoritedTime" = "気に入った時間の新しい順"; + +"eh.setting.view.section.title.ratings" = "評価"; +"eh.setting.view.title.ratingsColor" = "評価の色"; +"eh.setting.view.promt.ratingsColor" = "RRGGB"; +"eh.setting.view.description.ratingsColor" = "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。"; + +"eh.setting.view.section.title.tagsNamespaces" = "タグの名前空間"; +"eh.setting.view.description.tagsNamespaces" = "下にある名前空間を削除仕様にすることで通常のタグ検索結果から排除できます。注意:排除された名前空間のタグを持つギャラリーが表示されなくなることはありません。"; + +"eh.setting.view.section.title.tagFilteringThreshold" = "タグフィルタリングしきい値"; +"eh.setting.view.title.tagFilteringThreshold" = "タグフィルタリングしきい値"; +"eh.setting.view.description.tagFilteringThreshold" = "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。"; + +"eh.setting.view.section.title.tagWatchingThreshold" = "タグ購読しきい値"; +"eh.setting.view.title.tagWatchingThreshold" = "タグ購読しきい値"; +"eh.setting.view.description.tagWatchingThreshold" = "もしあるギャラリーは最近投稿されたもので、少なくても一つの正の重みの購読タグを持っていて、購読タグの重み総和がこのしきい値と同じまたはより高ければ、そのギャラリーは購読画面で表示されます。このしきい値はゼロから 9999 まで設定できます。"; + +"eh.setting.view.section.title.excludedLanguages" = "排除された言語"; +"eh.setting.view.description.excludedLanguages" = "特定の言語のギャラリーをリストと検索結果から隠したい場合、下に選択してください。注意:どんな検索クエリーを使ってもこれらの言語のギャラリーは表示されません。"; +// EhSetting.ExcludedLanguagesCategory +"enum.eh.setting.excluded.languages.category.value.original" = "オリジナル"; +"enum.eh.setting.excluded.languages.category.value.translated" = "翻訳版"; +"enum.eh.setting.excluded.languages.category.value.rewrite" = "書き換え版"; + +"eh.setting.view.section.title.excludedUploaders" = "排除された投稿者"; +"eh.setting.view.description.excludedUploaders" = "特定の投稿者のギャラリーをリストと検索結果から隠したい場合、下に名前を記入してください。一行に一つのユーザー名で。注意:どんな検索クエリーを使ってもこの投稿者たちのギャラリーは表示されません。"; +"eh.setting.view.description.excludedUploadersCount" = "現時点で **%@ / %@** の排除スロットが使用済みです。"; + +"eh.setting.view.section.title.searchResultCount" = "検索結果数"; +"eh.setting.view.title.resultCount" = "結果数"; +"eh.setting.view.description.resultCount" = "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\n(「Hath Perk:ページング拡張」が必要)"; + +"eh.setting.view.section.title.thumbnailSettings" = "サムネイル設定"; +"eh.setting.view.title.thumbnailLoadTiming" = "サムネイル読み込みタイミング"; +"eh.setting.view.description.thumbnailLoadTiming" = "リストでは、どんなタイミングでホームページのマウスオーバーサムネイルを読み込みますか?"; +"eh.setting.view.description.thumbnailConfiguration" = "すべてのギャラリーに適応するデフォルトのサムネイル構成を設定できます。"; +"eh.setting.view.title.thumbnailSize" = "サイズ"; +"eh.setting.view.title.thumbnailRowCount" = "行数"; +// EhSetting.ThumbnailLoadTiming +"enum.eh.setting.thumbnail.load.timing.value.onMouseOver" = "マウス経過時"; +"enum.eh.setting.thumbnail.load.timing.value.onPageLoad" = "ページ読み込み時"; +"enum.eh.setting.thumbnail.load.timing.description.onMouseOver" = "ページの読み込みが速くなりますが、サムネイルの表示はちょっぴり遅れてきます。"; +"enum.eh.setting.thumbnail.load.timing.description.onPageLoad" = "ページの読み込み時間が増えますが、サムネイルはすぐに表示できます。"; +// EhSetting.ThumbnailSize +"enum.eh.setting.thumbnail.size.value.normal" = "普通"; +"enum.eh.setting.thumbnail.size.value.large" = "大きめ"; + +"eh.setting.view.section.title.thumbnailScaling" = "サムネイルスケーリング"; +"eh.setting.view.title.scaleFactor" = "スケール係数"; +"eh.setting.view.description.scaleFactor" = "サムネイル・拡張表示モードでのサムネイルを 75% ~ 150% のカスタム値にスケールすることができます。"; + +"eh.setting.view.section.title.viewportOverride" = "表示領域オーバーライド"; +"eh.setting.view.title.virtualWidth" = "仮想幅"; +"eh.setting.view.description.virtualWidth" = "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100% の場合、640 ~ 1400 の幅が合理的です。"; + +"eh.setting.view.section.title.galleryComments" = "ギャラリーコメント"; +"eh.setting.view.title.commentsSortOrder" = "コメントの並び替え"; +"eh.setting.view.title.commentsVotesShowTiming" = "コメントスコア表示タイミング"; +// EhSetting.CommentsSortOrder +"enum.eh.setting.comments.sort.order.value.oldest" = "コメントの古い順"; +"enum.eh.setting.comments.sort.order.value.recent" = "コメントの新しい順"; +"enum.eh.setting.comments.sort.order.value.highestScore" = "スコアの高い順"; +// EhSetting.CommentVotesShowTiming +"enum.eh.setting.comments.votes.show.timing.value.onHoverOrClick" = "スコアに経過・クリック時"; +"enum.eh.setting.comments.votes.show.timing.value.always" = "常時"; + +"eh.setting.view.section.title.galleryTags" = "ギャラリータグ"; +"eh.setting.view.title.tagsSortOrder" = "タグの並び替え"; +// EhSetting.TagsSortOrder +"enum.eh.setting.tags.sort.order.value.alphabetical" = "アルファベット順"; +"enum.eh.setting.tags.sort.order.value.tagPower" = "タグパワーの高い順"; + +"eh.setting.view.section.title.galleryPageNumbering" = "ギャラリーページ数"; +"eh.setting.view.title.showGalleryPageNumbers" = "ギャラリーページ数を表示"; + +"eh.setting.view.section.title.hathLocalNetworkHost" = "Hath ローカルネットワークホスト"; +"eh.setting.view.title.ipAddressPort" = "IP アドレス:ポート"; +"eh.setting.view.description.ipAddressPort" = "ローカルネットワークで今と同じパブリック IP を使う H@H クライアントがお持ちの場合、この設定が役立ちます。ルーターがバグが多くてリクエストを自分の IP にルートすることができないこともあります、それをこの設定で回避できます。\nH@H クライアントが今と同じデバイスで運行している場合はループバックアドレス(127.0.0.1:ポート)を使ってください。別のデバイスの場合はそのローカル IP を使ってください。かなりのブラウザの構成では外部サイトがローカル IP にアクセスすることをブロックしています、この設定を有効にするには本サイトをホワイトリストに入れてください。"; + +"eh.setting.view.section.title.originalImages" = "オリジナル画像"; +"eh.setting.view.title.useOriginalImages" = "オリジナル画像を使う"; + +"eh.setting.view.section.title.multiPageViewer" = "マルチページビューア"; +"eh.setting.view.title.useMultiPageViewer" = "マルチページビューアを使う"; +"eh.setting.view.title.displayStyle" = "表示仕様"; +"eh.setting.view.title.showThumbnailPane" = "サムネイルパネルを表示"; +// EhSetting.MultiplePageViewerStyle +"enum.eh.setting.multiple.page.viewer.style.value.alignLeftScaleIfOverWidth" = "左寄せ、幅によってスケール"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterScaleIfOverWidth" = "中央揃え、幅によってスケール"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterAlwaysScale" = "中央揃え、常時スケール"; // MARK: Category -"Doujinshi" = "同人誌"; -"Manga" = "漫画"; -"Artist CG" = "イラスト"; -"Game CG" = "ゲーム CG"; -"Western" = "西洋"; -"Non-H" = "健全"; -"Image Set" = "画像集"; -"Cosplay" = "コスプレ"; -"Asian Porn" = "アジア"; -"Misc" = "その他"; -"Private" = "プライベート"; +"enum.category.value.doujinshi" = "同人誌"; +"enum.category.value.manga" = "漫画"; +"enum.category.value.artistCG" = "イラスト"; +"enum.category.value.gameCG" = "ゲーム CG"; +"enum.category.value.western" = "西洋"; +"enum.category.value.nonH" = "健全"; +"enum.category.value.imageSet" = "画像集"; +"enum.category.value.cosplay" = "コスプレ"; +"enum.category.value.asianPorn" = "アジア"; +"enum.category.value.misc" = "その他"; +"enum.category.value.private" = "プライベート"; // MARK: TagCategory -"Reclass" = "再分類"; -"Language" = "言語"; -"Parody" = "原作"; -"Character" = "キャラ"; -"Group" = "団体"; -"Artist" = "作者"; -"Male" = "男性"; -"Female" = "女性"; -"Mixed" = "混在性別"; -"Cosplayer" = "レイヤー"; -"Other" = "その他"; -"Temp" = "一時的"; - -// MARK: IconType -"Normal" = "普通"; -"Default" = "既定"; -"Weird" = "怪奇"; - -// MARK: PreferredColorScheme -"Automatic" = "自動"; -"Light" = "ライト"; -"Dark" = "ダーク"; - -// MARK: AutoLockPolicy -"Never" = "なし"; -"Instantly" = "すぐに"; -"%lld seconds" = "%lld 秒"; -"%lld minute" = "%lld 分"; -"%lld minutes" = "%lld 分"; +"enum.tag.category.value.reclass" = "再分類"; +"enum.tag.category.value.language" = "言語"; +"enum.tag.category.value.parody" = "原作"; +"enum.tag.category.value.character" = "キャラ"; +"enum.tag.category.value.group" = "団体"; +"enum.tag.category.value.artist" = "作者"; +"enum.tag.category.value.male" = "男性"; +"enum.tag.category.value.female" = "女性"; +"enum.tag.category.value.mixed" = "混在性別"; +"enum.tag.category.value.cosplayer" = "レイヤー"; +"enum.tag.category.value.other" = "その他"; +"enum.tag.category.value.temp" = "一時的"; // MARK: Language -"LANGUAGE_OTHER" = "その他"; -"LANGUAGE_INVALID" = "無効"; - -"Afrikaans" = "アフリカーンス語"; "Albanian" = "アルバニア語"; "Arabic" = "アラビア語"; - -"Bengali" = "ベンガル語"; "Bosnian" = "ボスニア語"; "Bulgarian" = "ブルガリア語"; "Burmese" = "ビルマ語"; - -"Catalan" = "カタルーニャ語"; "Cebuano" = "セブアノ語"; "Chinese" = "中国語"; "Croatian" = "クロアチア語"; "Czech" = "チェコ語"; - -"Danish" = "デンマーク語"; "Dutch" = "オランダ語"; - -"English" = "英語"; "Esperanto" = "国際語"; "Estonian" = "エストニア語"; - -"Finnish" = "フィンランド語"; "French" = "フランス語"; - -"Georgian" = "グルジア語"; "German" = "ドイツ語"; "Greek" = "ギリシア語"; - -"Hebrew" = "ヘブライ語"; "Hindi" = "ヒンディー語"; "Hmong" = "ミャオ語"; "Hungarian" = "ハンガリー語"; - -"Indonesian" = "インドネシア語"; "Italian" = "イタリア語"; - -"Japanese" = "日本語"; - -"Kazakh" = "カザフ語"; "Khmer" = "クメール語"; "Korean" = "韓国語"; "Kurdish" = "クルド語"; - -"Lao" = "ラーオ語"; "Latin" = "ラテン語"; - -"Mongolian" = "モンゴル語"; - -"Ndebele" = "ンデベレ語"; "Nepali" = "ネパール語"; "Norwegian" = "ノルウェー語"; - -"Oromo" = "オロモ語"; - -"Pashto" = "パシュトー語"; "Persian" = "ペルシア語"; "Polish" = "ポーランド語"; "Portuguese" = "ポルトガル語"; "Punjabi" = "パンジャーブ語"; - -"Romanian" = "ルーマニア語"; "Russian" = "ロシア語"; - -"Sango" = "サンゴ語"; "Serbian" = "セルビア語"; "Shona" = "ショナ語"; "Slovak" = "スロバキア語"; "Slovenian" = "スロベニア語"; "Somali" = "ソマリ語"; "Spanish" = "スペイン語"; "Swahili" = "スワヒリ語"; "Swedish" = "スウェーデン語"; - -"Tagalog" = "タガログ語"; "Thai" = "タイ語"; "Tigrinya" = "ティグリニャ語"; "Turkish" = "トルコ語"; - -"Ukrainian" = "ウクライナ語"; "Urdu" = "ウルドゥー語"; - -"Vietnamese" = "ベトナム語"; - -"Zulu" = "ズールー語"; - -// MARK: EhSettingCountry -"Auto-Detect" = "自動検出"; "Afghanistan" = "アフガニスタン"; "Aland Islands" = "オーランド諸島"; "Albania" = "アルバニア"; "Algeria" = "アルジェリア"; "American Samoa" = "アメリカ領サモア"; "Andorra" = "アンドラ"; "Angola" = "アンゴラ"; "Anguilla" = "アンギラ"; "Antarctica" = "南極大陸"; "Antigua and Barbuda" = "アンティグア・バーブーダ"; "Argentina" = "アルゼンチン"; "Armenia" = "アルメニア"; "Aruba" = "アルバ"; "Asia-Pacific Region" = "アジア太平洋地域"; "Australia" = "オーストラリア"; "Austria" = "オーストリア"; "Azerbaijan" = "アゼルバイジャン"; "Bahamas" = "バハマ"; "Bahrain" = "バーレーン"; "Bangladesh" = "バングラデシュ"; "Barbados" = "バルバドス"; "Belarus" = "ベラルーシ"; "Belgium" = "ベルギー"; "Belize" = "ベリーズ"; "Benin" = "ベナン"; "Bermuda" = "バミューダ諸島"; "Bhutan" = "ブータン"; "Bolivia" = "ボリビア"; "Bonaire Saint Eustatius and Saba" = "ボネール、シント・ユースタティウスおよびサバ"; "Bosnia and Herzegovina" = "ボスニア・ヘルツェゴビナ"; "Botswana" = "ボツワナ"; "Bouvet Island" = "ブーベ島"; "Brazil" = "ブラジル"; "British Indian Ocean Territory" = "イギリス領インド洋地域"; "Brunei Darussalam" = "ブルネイ・ダルサラーム"; "Bulgaria" = "ブルガリア"; "Burkina Faso" = "ブルキナファソ"; "Burundi" = "ブルンジ"; "Cambodia" = "カンボジア"; "Cameroon" = "カメルーン"; "Canada" = "カナダ"; "Cape Verde" = "カーボベルデ"; "Cayman Islands" = "ケイマン諸島"; "Central African Republic" = "中央アフリカ共和国"; "Chad" = "チャド"; "Chile" = "チリ"; "China" = "中華人民共和国"; "Christmas Island" = "クリスマス島"; "Cocos Islands" = "ココス諸島"; "Colombia" = "コロンビア"; "Comoros" = "コモロ"; "Congo" = "コンゴ共和国"; "The Democratic Republic of the Congo" = "コンゴ民主共和国"; "Cook Islands" = "クック諸島"; "Costa Rica" = "コスタリカ"; "Cote D'Ivoire" = "コートジボワール"; "Croatia" = "クロアチア"; "Cuba" = "キューバ"; "Curacao" = "キュラソー島"; "Cyprus" = "キプロス"; "Czech Republic" = "チェコ"; "Denmark" = "デンマーク"; "Djibouti" = "ジブチ"; "Dominica" = "ドミニカ"; "Dominican Republic" = "ドミニカ共和国"; "Ecuador" = "エクアドル"; "Egypt" = "エジプト"; "El Salvador" = "エルサルバドル"; "Equatorial Guinea" = "赤道ギニア"; "Eritrea" = "エリトリア"; "Estonia" = "エストニア"; "Ethiopia" = "エチオピア"; "Europe" = "ヨーロッパ"; "Falkland Islands" = "フォークランド諸島"; "Faroe Islands" = "フェロー諸島"; "Fiji" = "フィジー"; "Finland" = "フィンランド"; "France" = "フランス"; "French Guiana" = "フランス領ギアナ"; "French Polynesia" = "フランス領ポリネシア"; "French Southern Territories" = "フランス領南方・南極地域"; "Gabon" = "ガボン"; "Gambia" = "ガンビア"; "Georgia" = "ジョージア"; "Germany" = "ドイツ"; "Ghana" = "ガーナ"; "Gibraltar" = "ジブラルタル"; "Greece" = "ギリシャ"; "Greenland" = "グリーンランド"; "Grenada" = "グレナダ"; "Guadeloupe" = "グアドループ"; "Guam" = "グアム"; "Guatemala" = "グアテマラ"; "Guernsey" = "ガーンジー"; "Guinea" = "ギニア"; "Guinea-Bissau" = "ギニアビサウ"; "Guyana" = "ガイアナ"; "Haiti" = "ハイチ"; "Heard Island and McDonald Islands" = "ハード島とマクドナルド諸島"; "Vatican City State" = "バチカン市国"; "Honduras" = "ホンジュラス"; "Hong Kong" = "香港"; "Hungary" = "ハンガリー"; "Iceland" = "アイスランド"; "India" = "インド"; "Indonesia" = "インドネシア"; "Iran" = "イラン"; "Iraq" = "イラク"; "Ireland" = "アイルランド"; "Isle of Man" = "マン島"; "Israel" = "イスラエル"; "Italy" = "イタリア"; "Jamaica" = "ジャマイカ"; "Japan" = "日本"; "Jersey" = "ジャージー"; "Jordan" = "ヨルダン"; "Kazakhstan" = "カザフスタン"; "Kenya" = "ケニア"; "Kiribati" = "キリバス"; "Kuwait" = "クウェート"; "Kyrgyzstan" = "キルギス"; "Lao People's Democratic Republic" = "ラオス"; "Latvia" = "ラトビア"; "Lebanon" = "レバノン"; "Lesotho" = "レソト"; "Liberia" = "リベリア"; "Libya" = "リビア"; "Liechtenstein" = "リヒテンシュタイン"; "Lithuania" = "リトアニア"; "Luxembourg" = "ルクセンブルク"; "Macau" = "マカオ"; "Macedonia" = "マケドニア"; "Madagascar" = "マダガスカル"; "Malawi" = "マラウイ"; "Malaysia" = "マレーシア"; "Maldives" = "モルディブ"; "Mali" = "マリ"; "Malta" = "マルタ"; "Marshall Islands" = "マーシャル諸島"; "Martinique" = "マルティニーク"; "Mauritania" = "モーリタニア"; "Mauritius" = "モーリシャス"; "Mayotte" = "マヨット"; "Mexico" = "メキシコ"; "Micronesia" = "ミクロネシア"; "Moldova" = "モルドバ"; "Monaco" = "モナコ"; "Mongolia" = "モンゴル"; "Montenegro" = "モンテネグロ"; "Montserrat" = "モントセラト"; "Morocco" = "モロッコ"; "Mozambique" = "モザンビーク"; "Myanmar" = "ミャンマー"; "Namibia" = "ナミビア"; "Nauru" = "ナウル"; "Nepal" = "ネパール"; "Netherlands" = "オランダ"; "New Caledonia" = "ニューカレドニア"; "New Zealand" = "ニュージーランド"; "Nicaragua" = "ニカラグア"; "Niger" = "ニジェール"; "Nigeria" = "ナイジェリア"; "Niue" = "ニウエ"; "Norfolk Island" = "ノーフォーク島"; "North Korea" = "朝鮮"; "Northern Mariana Islands" = "北マリアナ諸島"; "Norway" = "ノルウェー"; "Oman" = "オマーン"; "Pakistan" = "パキスタン"; "Palau" = "パラオ"; "Palestinian Territory" = "パレスチナ"; "Panama" = "パナマ"; "Papua New Guinea" = "パプアニューギニア"; "Paraguay" = "パラグアイ"; "Peru" = "ペルー"; "Philippines" = "フィリピン"; "Pitcairn Islands" = "ピトケアン諸島"; "Poland" = "ポーランド"; "Portugal" = "ポルトガル"; "Puerto Rico" = "プエルトリコ"; "Qatar" = "カタール"; "Reunion" = "ユニオン"; "Romania" = "ルーマニア"; "Russian Federation" = "ロシア"; "Rwanda" = "ルワンダ"; "Saint Barthelemy" = "サン・バルテルミー島"; "Saint Helena" = "セントヘレナ"; "Saint Kitts and Nevis" = "セントクリストファー・ネービス"; "Saint Lucia" = "セントルシア"; "Saint Martin" = "サン・マルタン島"; "Saint Pierre and Miquelon" = "サンピエール島・ミクロン島"; "Saint Vincent and the Grenadines" = "セントビンセントおよびグレナディーン諸島"; "Samoa" = "サモア"; "San Marino" = "サンマリノ"; "Sao Tome and Principe" = "サントメ・プリンシペ"; "Saudi Arabia" = "サウジアラビア"; "Senegal" = "セネガル"; "Serbia" = "セルビア"; "Seychelles" = "セーシェル"; "Sierra Leone" = "シエラレオネ"; "Singapore" = "シンガポール"; "Sint Maarten" = "シント・マールテン"; "Slovakia" = "スロバキア"; "Slovenia" = "スロベニア"; "Solomon Islands" = "ソロモン諸島"; "Somalia" = "ソマリア"; "South Africa" = "南アフリカ"; "South Georgia and the South Sandwich Islands" = "サウスジョージア・サウスサンドウィッチ諸島"; "South Korea" = "韓国"; "South Sudan" = "南スーダン"; "Spain" = "スペイン"; "Sri Lanka" = "スリランカ"; "Sudan" = "スーダン"; "Suriname" = "スリナム"; "Svalbard and Jan Mayen" = "スヴァールバル諸島およびヤンマイエン島"; "Swaziland" = "エスワティニ"; "Sweden" = "スウェーデン"; "Switzerland" = "スイス"; "Syrian Arab Republic" = "シリア"; "Taiwan" = "台湾"; "Tajikistan" = "タジキスタン"; "Tanzania" = "タンザニア"; "Thailand" = "タイ"; "Timor-Leste" = "東ティモール"; "Togo" = "トーゴ"; "Tokelau" = "トケラウ"; "Tonga" = "トンガ"; "Trinidad and Tobago" = "トリニダード・トバゴ"; "Tunisia" = "チュニジア"; "Turkey" = "トルコ"; "Turkmenistan" = "トルクメニスタン"; "Turks and Caicos Islands" = "タークス・カイコス諸島"; "Tuvalu" = "ツバル"; "Uganda" = "ウガンダ"; "Ukraine" = "ウクライナ"; "United Arab Emirates" = "アラブ首長国連邦"; "United Kingdom" = "イギリス"; "United States" = "アメリカ"; "United States Minor Outlying Islands" = "合衆国領有小離島"; "Uruguay" = "ウルグアイ"; "Uzbekistan" = "ウズベキスタン"; "Vanuatu" = "バヌアツ"; "Venezuela" = "ベネズエラ"; "Vietnam" = "ベトナム"; "British Virgin Islands" = "イギリス領バージン諸島"; "U.S. Virgin Islands" = "アメリカ領ヴァージン諸島"; "Wallis and Futuna" = "ウォリス・フツナ"; "Western Sahara" = "西サハラ"; "Yemen" = "イエメン"; "Zambia" = "ザンビア"; "Zimbabwe" = "ジンバブエ"; +"enum.language.value.invalid" = "無効"; +"enum.language.value.other" = "その他"; +"enum.language.value.afrikaans" = "アフリカーンス語"; +"enum.language.value.albanian" = "アルバニア語"; +"enum.language.value.arabic" = "アラビア語"; +"enum.language.value.bengali" = "ベンガル語"; +"enum.language.value.bosnian" = "ボスニア語"; +"enum.language.value.bulgarian" = "ブルガリア語"; +"enum.language.value.burmese" = "ビルマ語"; +"enum.language.value.catalan" = "カタルーニャ語"; +"enum.language.value.cebuano" = "セブアノ語"; +"enum.language.value.chinese" = "中国語"; +"enum.language.value.croatian" = "クロアチア語"; +"enum.language.value.czech" = "チェコ語"; +"enum.language.value.danish" = "デンマーク語"; +"enum.language.value.dutch" = "オランダ語"; +"enum.language.value.english" = "英語"; +"enum.language.value.esperanto" = "国際語"; +"enum.language.value.estonian" = "エストニア語"; +"enum.language.value.finnish" = "フィンランド語"; +"enum.language.value.french" = "フランス語"; +"enum.language.value.georgian" = "グルジア語"; +"enum.language.value.german" = "ドイツ語"; +"enum.language.value.greek" = "ギリシア語"; +"enum.language.value.hebrew" = "ヘブライ語"; +"enum.language.value.hindi" = "ヒンディー語"; +"enum.language.value.hmong" = "ミャオ語"; +"enum.language.value.hungarian" = "ハンガリー語"; +"enum.language.value.indonesian" = "インドネシア語"; +"enum.language.value.italian" = "イタリア語"; +"enum.language.value.japanese" = "日本語"; +"enum.language.value.kazakh" = "カザフ語"; +"enum.language.value.khmer" = "クメール語"; +"enum.language.value.korean" = "韓国語"; +"enum.language.value.kurdish" = "クルド語"; +"enum.language.value.lao" = "ラーオ語"; +"enum.language.value.latin" = "ラテン語"; +"enum.language.value.mongolian" = "モンゴル語"; +"enum.language.value.ndebele" = "ンデベレ語"; +"enum.language.value.nepali" = "ネパール語"; +"enum.language.value.norwegian" = "ノルウェー語"; +"enum.language.value.oromo" = "オロモ語"; +"enum.language.value.pashto" = "パシュトー語"; +"enum.language.value.persian" = "ペルシア語"; +"enum.language.value.polish" = "ポーランド語"; +"enum.language.value.portuguese" = "ポルトガル語"; +"enum.language.value.punjabi" = "パンジャーブ語"; +"enum.language.value.romanian" = "ルーマニア語"; +"enum.language.value.russian" = "ロシア語"; +"enum.language.value.sango" = "サンゴ語"; +"enum.language.value.serbian" = "セルビア語"; +"enum.language.value.shona" = "ショナ語"; +"enum.language.value.slovak" = "スロバキア語"; +"enum.language.value.slovenian" = "スロベニア語"; +"enum.language.value.somali" = "ソマリ語"; +"enum.language.value.spanish" = "スペイン語"; +"enum.language.value.swahili" = "スワヒリ語"; +"enum.language.value.swedish" = "スウェーデン語"; +"enum.language.value.tagalog" = "タガログ語"; +"enum.language.value.thai" = "タイ語"; +"enum.language.value.tigrinya" = "ティグリニャ語"; +"enum.language.value.turkish" = "トルコ語"; +"enum.language.value.ukrainian" = "ウクライナ語"; +"enum.language.value.urdu" = "ウルドゥー語"; +"enum.language.value.vietnamese" = "ベトナム語"; +"enum.language.value.zulu" = "ズールー語"; + +// MARK: BrowsingCountry +"enum.browsing.country.name.autoDetect" = "自動検出"; +"enum.browsing.country.name.afghanistan" = "アフガニスタン"; +"enum.browsing.country.name.alandIslands" = "オーランド諸島"; +"enum.browsing.country.name.albania" = "アルバニア"; +"enum.browsing.country.name.algeria" = "アルジェリア"; +"enum.browsing.country.name.americanSamoa" = "アメリカ領サモア"; +"enum.browsing.country.name.andorra" = "アンドラ"; +"enum.browsing.country.name.angola" = "アンゴラ"; +"enum.browsing.country.name.anguilla" = "アンギラ"; +"enum.browsing.country.name.antarctica" = "南極大陸"; +"enum.browsing.country.name.antiguaAndBarbuda" = "アンティグア・バーブーダ"; +"enum.browsing.country.name.argentina" = "アルゼンチン"; +"enum.browsing.country.name.armenia" = "アルメニア"; +"enum.browsing.country.name.aruba" = "アルバ"; +"enum.browsing.country.name.asiaPacificRegion" = "アジア太平洋地域"; +"enum.browsing.country.name.australia" = "オーストラリア"; +"enum.browsing.country.name.austria" = "オーストリア"; +"enum.browsing.country.name.azerbaijan" = "アゼルバイジャン"; +"enum.browsing.country.name.bahamas" = "バハマ"; +"enum.browsing.country.name.bahrain" = "バーレーン"; +"enum.browsing.country.name.bangladesh" = "バングラデシュ"; +"enum.browsing.country.name.barbados" = "バルバドス"; +"enum.browsing.country.name.belarus" = "ベラルーシ"; +"enum.browsing.country.name.belgium" = "ベルギー"; +"enum.browsing.country.name.belize" = "ベリーズ"; +"enum.browsing.country.name.benin" = "ベナン"; +"enum.browsing.country.name.bermuda" = "バミューダ諸島"; +"enum.browsing.country.name.bhutan" = "ブータン"; +"enum.browsing.country.name.bolivia" = "ボリビア"; +"enum.browsing.country.name.bonaireSaintEustatiusAndSaba" = "ボネール、シント・ユースタティウスおよびサバ"; +"enum.browsing.country.name.bosniaAndHerzegovina" = "ボスニア・ヘルツェゴビナ"; +"enum.browsing.country.name.botswana" = "ボツワナ"; +"enum.browsing.country.name.bouvetIsland" = "ブーベ島"; +"enum.browsing.country.name.brazil" = "ブラジル"; +"enum.browsing.country.name.britishIndianOceanTerritory" = "イギリス領インド洋地域"; +"enum.browsing.country.name.bruneiDarussalam" = "ブルネイ・ダルサラーム"; +"enum.browsing.country.name.bulgaria" = "ブルガリア"; +"enum.browsing.country.name.burkinaFaso" = "ブルキナファソ"; +"enum.browsing.country.name.burundi" = "ブルンジ"; +"enum.browsing.country.name.cambodia" = "カンボジア"; +"enum.browsing.country.name.cameroon" = "カメルーン"; +"enum.browsing.country.name.canada" = "カナダ"; +"enum.browsing.country.name.capeVerde" = "カーボベルデ"; +"enum.browsing.country.name.caymanIslands" = "ケイマン諸島"; +"enum.browsing.country.name.centralAfricanRepublic" = "中央アフリカ共和国"; +"enum.browsing.country.name.chad" = "チャド"; +"enum.browsing.country.name.chile" = "チリ"; +"enum.browsing.country.name.china" = "中華人民共和国"; +"enum.browsing.country.name.christmasIsland" = "クリスマス島"; +"enum.browsing.country.name.cocosIslands" = "ココス諸島"; +"enum.browsing.country.name.colombia" = "コロンビア"; +"enum.browsing.country.name.comoros" = "コモロ"; +"enum.browsing.country.name.congo" = "コンゴ共和国"; +"enum.browsing.country.name.theDemocraticRepublicOfTheCongo" = "コンゴ民主共和国"; +"enum.browsing.country.name.cookIslands" = "クック諸島"; +"enum.browsing.country.name.costaRica" = "コスタリカ"; +"enum.browsing.country.name.coteDIvoire" = "コートジボワール"; +"enum.browsing.country.name.croatia" = "クロアチア"; +"enum.browsing.country.name.cuba" = "キューバ"; +"enum.browsing.country.name.curacao" = "キュラソー島"; +"enum.browsing.country.name.cyprus" = "キプロス"; +"enum.browsing.country.name.czechRepublic" = "チェコ"; +"enum.browsing.country.name.denmark" = "デンマーク"; +"enum.browsing.country.name.djibouti" = "ジブチ"; +"enum.browsing.country.name.dominica" = "ドミニカ"; +"enum.browsing.country.name.dominicanRepublic" = "ドミニカ共和国"; +"enum.browsing.country.name.ecuador" = "エクアドル"; +"enum.browsing.country.name.egypt" = "エジプト"; +"enum.browsing.country.name.elSalvador" = "エルサルバドル"; +"enum.browsing.country.name.equatorialGuinea" = "赤道ギニア"; +"enum.browsing.country.name.eritrea" = "エリトリア"; +"enum.browsing.country.name.estonia" = "エストニア"; +"enum.browsing.country.name.ethiopia" = "エチオピア"; +"enum.browsing.country.name.europe" = "ヨーロッパ"; +"enum.browsing.country.name.falklandIslands" = "フォークランド諸島"; +"enum.browsing.country.name.faroeIslands" = "フェロー諸島"; +"enum.browsing.country.name.fiji" = "フィジー"; +"enum.browsing.country.name.finland" = "フィンランド"; +"enum.browsing.country.name.france" = "フランス"; +"enum.browsing.country.name.frenchGuiana" = "フランス領ギアナ"; +"enum.browsing.country.name.frenchPolynesia" = "フランス領ポリネシア"; +"enum.browsing.country.name.frenchSouthernTerritories" = "フランス領南方・南極地域"; +"enum.browsing.country.name.gabon" = "ガボン"; +"enum.browsing.country.name.gambia" = "ガンビア"; +"enum.browsing.country.name.georgia" = "ジョージア"; +"enum.browsing.country.name.germany" = "ドイツ"; +"enum.browsing.country.name.ghana" = "ガーナ"; +"enum.browsing.country.name.gibraltar" = "ジブラルタル"; +"enum.browsing.country.name.greece" = "ギリシャ"; +"enum.browsing.country.name.greenland" = "グリーンランド"; +"enum.browsing.country.name.grenada" = "グレナダ"; +"enum.browsing.country.name.guadeloupe" = "グアドループ"; +"enum.browsing.country.name.guam" = "グアム"; +"enum.browsing.country.name.guatemala" = "グアテマラ"; +"enum.browsing.country.name.guernsey" = "ガーンジー"; +"enum.browsing.country.name.guinea" = "ギニア"; +"enum.browsing.country.name.guineaBissau" = "ギニアビサウ"; +"enum.browsing.country.name.guyana" = "ガイアナ"; +"enum.browsing.country.name.haiti" = "ハイチ"; +"enum.browsing.country.name.heardIslandAndMcDonaldIslands" = "ハード島とマクドナルド諸島"; +"enum.browsing.country.name.vaticanCityState" = "バチカン市国"; +"enum.browsing.country.name.honduras" = "ホンジュラス"; +"enum.browsing.country.name.hongKong" = "香港"; +"enum.browsing.country.name.hungary" = "ハンガリー"; +"enum.browsing.country.name.iceland" = "アイスランド"; +"enum.browsing.country.name.india" = "インド"; +"enum.browsing.country.name.indonesia" = "インドネシア"; +"enum.browsing.country.name.iran" = "イラン"; +"enum.browsing.country.name.iraq" = "イラク"; +"enum.browsing.country.name.ireland" = "アイルランド"; +"enum.browsing.country.name.isleOfMan" = "マン島"; +"enum.browsing.country.name.israel" = "イスラエル"; +"enum.browsing.country.name.italy" = "イタリア"; +"enum.browsing.country.name.jamaica" = "ジャマイカ"; +"enum.browsing.country.name.japan" = "日本"; +"enum.browsing.country.name.jersey" = "ジャージー"; +"enum.browsing.country.name.jordan" = "ヨルダン"; +"enum.browsing.country.name.kazakhstan" = "カザフスタン"; +"enum.browsing.country.name.kenya" = "ケニア"; +"enum.browsing.country.name.kiribati" = "キリバス"; +"enum.browsing.country.name.kuwait" = "クウェート"; +"enum.browsing.country.name.kyrgyzstan" = "キルギス"; +"enum.browsing.country.name.laoPeoplesDemocraticRepublic" = "ラオス"; +"enum.browsing.country.name.latvia" = "ラトビア"; +"enum.browsing.country.name.lebanon" = "レバノン"; +"enum.browsing.country.name.lesotho" = "レソト"; +"enum.browsing.country.name.liberia" = "リベリア"; +"enum.browsing.country.name.libya" = "リビア"; +"enum.browsing.country.name.liechtenstein" = "リヒテンシュタイン"; +"enum.browsing.country.name.lithuania" = "リトアニア"; +"enum.browsing.country.name.luxembourg" = "ルクセンブルク"; +"enum.browsing.country.name.macau" = "マカオ"; +"enum.browsing.country.name.macedonia" = "マケドニア"; +"enum.browsing.country.name.madagascar" = "マダガスカル"; +"enum.browsing.country.name.malawi" = "マラウイ"; +"enum.browsing.country.name.malaysia" = "マレーシア"; +"enum.browsing.country.name.maldives" = "モルディブ"; +"enum.browsing.country.name.mali" = "マリ"; +"enum.browsing.country.name.malta" = "マルタ"; +"enum.browsing.country.name.marshallIslands" = "マーシャル諸島"; +"enum.browsing.country.name.martinique" = "マルティニーク"; +"enum.browsing.country.name.mauritania" = "モーリタニア"; +"enum.browsing.country.name.mauritius" = "モーリシャス"; +"enum.browsing.country.name.mayotte" = "マヨット"; +"enum.browsing.country.name.mexico" = "メキシコ"; +"enum.browsing.country.name.micronesia" = "ミクロネシア"; +"enum.browsing.country.name.moldova" = "モルドバ"; +"enum.browsing.country.name.monaco" = "モナコ"; +"enum.browsing.country.name.mongolia" = "モンゴル"; +"enum.browsing.country.name.montenegro" = "モンテネグロ"; +"enum.browsing.country.name.montserrat" = "モントセラト"; +"enum.browsing.country.name.morocco" = "モロッコ"; +"enum.browsing.country.name.mozambique" = "モザンビーク"; +"enum.browsing.country.name.myanmar" = "ミャンマー"; +"enum.browsing.country.name.namibia" = "ナミビア"; +"enum.browsing.country.name.nauru" = "ナウル"; +"enum.browsing.country.name.nepal" = "ネパール"; +"enum.browsing.country.name.netherlands" = "オランダ"; +"enum.browsing.country.name.newCaledonia" = "ニューカレドニア"; +"enum.browsing.country.name.newZealand" = "ニュージーランド"; +"enum.browsing.country.name.nicaragua" = "ニカラグア"; +"enum.browsing.country.name.niger" = "ニジェール"; +"enum.browsing.country.name.nigeria" = "ナイジェリア"; +"enum.browsing.country.name.niue" = "ニウエ"; +"enum.browsing.country.name.norfolkIsland" = "ノーフォーク島"; +"enum.browsing.country.name.northKorea" = "朝鮮"; +"enum.browsing.country.name.northernMarianaIslands" = "北マリアナ諸島"; +"enum.browsing.country.name.norway" = "ノルウェー"; +"enum.browsing.country.name.oman" = "オマーン"; +"enum.browsing.country.name.pakistan" = "パキスタン"; +"enum.browsing.country.name.palau" = "パラオ"; +"enum.browsing.country.name.palestinianTerritory" = "パレスチナ"; +"enum.browsing.country.name.panama" = "パナマ"; +"enum.browsing.country.name.papuaNewGuinea" = "パプアニューギニア"; +"enum.browsing.country.name.paraguay" = "パラグアイ"; +"enum.browsing.country.name.peru" = "ペルー"; +"enum.browsing.country.name.philippines" = "フィリピン"; +"enum.browsing.country.name.pitcairnIslands" = "ピトケアン諸島"; +"enum.browsing.country.name.poland" = "ポーランド"; +"enum.browsing.country.name.portugal" = "ポルトガル"; +"enum.browsing.country.name.puertoRico" = "プエルトリコ"; +"enum.browsing.country.name.qatar" = "カタール"; +"enum.browsing.country.name.reunion" = "ユニオン"; +"enum.browsing.country.name.romania" = "ルーマニア"; +"enum.browsing.country.name.russianFederation" = "ロシア"; +"enum.browsing.country.name.rwanda" = "ルワンダ"; +"enum.browsing.country.name.saintBarthelemy" = "サン・バルテルミー島"; +"enum.browsing.country.name.saintHelena" = "セントヘレナ"; +"enum.browsing.country.name.saintKittsAndNevis" = "セントクリストファー・ネービス"; +"enum.browsing.country.name.saintLucia" = "セントルシア"; +"enum.browsing.country.name.saintMartin" = "サン・マルタン島"; +"enum.browsing.country.name.saintPierreAndMiquelon" = "サンピエール島・ミクロン島"; +"enum.browsing.country.name.saintVincentAndTheGrenadines" = "セントビンセントおよびグレナディーン諸島"; +"enum.browsing.country.name.samoa" = "サモア"; +"enum.browsing.country.name.sanMarino" = "サンマリノ"; +"enum.browsing.country.name.saoTomeAndPrincipe" = "サントメ・プリンシペ"; +"enum.browsing.country.name.saudiArabia" = "サウジアラビア"; +"enum.browsing.country.name.senegal" = "セネガル"; +"enum.browsing.country.name.serbia" = "セルビア"; +"enum.browsing.country.name.seychelles" = "セーシェル"; +"enum.browsing.country.name.sierraLeone" = "シエラレオネ"; +"enum.browsing.country.name.singapore" = "シンガポール"; +"enum.browsing.country.name.sintMaarten" = "シント・マールテン"; +"enum.browsing.country.name.slovakia" = "スロバキア"; +"enum.browsing.country.name.slovenia" = "スロベニア"; +"enum.browsing.country.name.solomonIslands" = "ソロモン諸島"; +"enum.browsing.country.name.somalia" = "ソマリア"; +"enum.browsing.country.name.southAfrica" = "南アフリカ"; +"enum.browsing.country.name.southGeorgiaAndTheSouthSandwichIslands" = "サウスジョージア・サウスサンドウィッチ諸島"; +"enum.browsing.country.name.southKorea" = "韓国"; +"enum.browsing.country.name.southSudan" = "南スーダン"; +"enum.browsing.country.name.spain" = "スペイン"; +"enum.browsing.country.name.sriLanka" = "スリランカ"; +"enum.browsing.country.name.sudan" = "スーダン"; +"enum.browsing.country.name.suriname" = "スリナム"; +"enum.browsing.country.name.svalbardAndJanMayen" = "スヴァールバル諸島およびヤンマイエン島"; +"enum.browsing.country.name.swaziland" = "エスワティニ"; +"enum.browsing.country.name.sweden" = "スウェーデン"; +"enum.browsing.country.name.switzerland" = "スイス"; +"enum.browsing.country.name.syrianArabRepublic" = "シリア"; +"enum.browsing.country.name.taiwan" = "台湾"; +"enum.browsing.country.name.tajikistan" = "タジキスタン"; +"enum.browsing.country.name.tanzania" = "タンザニア"; +"enum.browsing.country.name.thailand" = "タイ"; +"enum.browsing.country.name.timorLeste" = "東ティモール"; +"enum.browsing.country.name.togo" = "トーゴ"; +"enum.browsing.country.name.tokelau" = "トケラウ"; +"enum.browsing.country.name.tonga" = "トンガ"; +"enum.browsing.country.name.trinidadAndTobago" = "トリニダード・トバゴ"; +"enum.browsing.country.name.tunisia" = "チュニジア"; +"enum.browsing.country.name.turkey" = "トルコ"; +"enum.browsing.country.name.turkmenistan" = "トルクメニスタン"; +"enum.browsing.country.name.turksAndCaicosIslands" = "タークス・カイコス諸島"; +"enum.browsing.country.name.tuvalu" = "ツバル"; +"enum.browsing.country.name.uganda" = "ウガンダ"; +"enum.browsing.country.name.ukraine" = "ウクライナ"; +"enum.browsing.country.name.unitedArabEmirates" = "アラブ首長国連邦"; +"enum.browsing.country.name.unitedKingdom" = "イギリス"; +"enum.browsing.country.name.unitedStates" = "アメリカ"; +"enum.browsing.country.name.unitedStatesMinorOutlyingIslands" = "合衆国領有小離島"; +"enum.browsing.country.name.uruguay" = "ウルグアイ"; +"enum.browsing.country.name.uzbekistan" = "ウズベキスタン"; +"enum.browsing.country.name.vanuatu" = "バヌアツ"; +"enum.browsing.country.name.venezuela" = "ベネズエラ"; +"enum.browsing.country.name.vietnam" = "ベトナム"; +"enum.browsing.country.name.virginIslandsBritish" = "イギリス領バージン諸島"; +"enum.browsing.country.name.virginIslandsUS" = "アメリカ領ヴァージン諸島"; +"enum.browsing.country.name.wallisAndFutuna" = "ウォリス・フツナ"; +"enum.browsing.country.name.westernSahara" = "西サハラ"; +"enum.browsing.country.name.yemen" = "イエメン"; +"enum.browsing.country.name.zambia" = "ザンビア"; +"enum.browsing.country.name.zimbabwe" = "ジンバブエ"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 68eaa0f6..b52b456b 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -6,469 +6,873 @@ */ +// MARK: BanInterval +"enum.ban.interval.description.and" = "and"; + +// MARK: ToplistsType +"enum.toplists.type.value.yesterday" = "어제"; +"enum.toplists.type.value.pastMonth" = "지난 달"; +"enum.toplists.type.value.pastYear" = "지난 해"; +"enum.toplists.type.value.allTime" = "전체"; + // MARK: Response -"You must have a H@H client assigned to your account to use this feature." = "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요."; -"Your H@H client appears to be offline. Turn it on, then try again." = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; -"The requested gallery cannot be downloaded with the selected resolution." = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; +"hath.download.response.hathClientNotFound" = "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요."; +"hath.download.response.hathClientNotOnline" = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; +"hath.download.response.invalidResolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; // MARK: HUD -"Success" = "성공"; -"Error" = "실패"; -"Communicating..." = "접속 중..."; -"Copied to clipboard" = "클립보드에 복사되었어요"; +"hud.title.error" = "실패"; +"hud.title.success" = "성공"; +"hud.title.loading" = "로딩 중..."; +"hud.title.communicating" = "접속 중..."; +"hud.caption.copiedToClipboard" = "클립보드에 복사되었어요"; +"hud.caption.savedToPhotoLibrary" = "이미지 저장"; + +// MARK: AutoLock +"local.authorization.reason" = "자동 잠금으로 앱이 잠겼어요."; + +// MARK: Common value +"common.value.stars" = "%@별"; +"common.value.pages" = "%@페이지"; +"common.value.times" = "%@번"; +"common.value.day" = "%@ day"; +"common.value.days" = "%@ days"; +"common.value.hour" = "%@ hour"; +"common.value.hours" = "%@ hours"; +"common.value.minute" = "%@ 분"; +"common.value.minutes" = "%@ 분"; +"common.value.second" = "%@ 초"; +"common.value.seconds" = "%@ 초"; +"common.value.records" = "%@ 기록수"; + +// MARK: TabItem +"tab.item.title.home" = "Home"; +"tab.item.title.favorites" = "즐겨찾기"; +"tab.item.title.search" = "검색"; +"tab.item.title.setting" = "설정"; + +// MARK: ToolbarItem +"toolbar.item.button.filters" = "필터"; +"toolbar.item.button.jumpPage" = "페이지 이동"; +"toolbar.item.button.quickSearch" = "빠른 검색"; + +// MARK: JumpPage +"jump.page.view.title.jumpPage" = "페이지 이동"; +"jump.page.view.button.confirm" = "확인"; -// MARK: LockView -"The App has been locked due to the auto-lock expiration." = "자동 잠금으로 앱이 잠겼어요."; +// MARK: AlertView +"loading.view.title.loading" = "로딩 중..."; +"loading.view.title.preparingDatabase" = "Preparing the database..."; +"not.login.view.title.needLogin" = "You need to login to access this feature."; +"not.login.view.button.login" = "Login"; +"error.view.button.retry" = "재시도"; +"error.view.button.dropDatabase" = "Drop the database"; +"error.view.title.tryLater" = "잠시 후 다시 시도해 주세요."; +"error.view.title.network" = "인터넷 접속 오류가 발생했어요."; +"error.view.title.parsing" = "구분 분석 오류가 발생했어요."; +"error.view.title.unknown" = "알 수 없는 오류가 발생했어요."; +"error.view.title.notFound" = "여기가 아무도 없는 것 같습니다."; +"error.view.title.databaseCorrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; +"error.view.title.ipBanned" = "자동화된 미러링/수집 소프트웨어를 사용 중임을 나타내는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 금지되었습니다. 금지효과는 %@ 에서 만료되었습니다."; +"error.view.title.copyrightClaim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; +"error.view.title.galleryUnavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; + +// MARK: ConfirmationDialog +"confirmation.dialog.title.dropDatabase" = "You will lose all your data in this app.\nAre you sure to drop the database?"; +"confirmation.dialog.title.removeCustomTranslations" = "Are you sure to remove your custom translations?"; +"confirmation.dialog.title.logout" = "로그아웃 하시겠어요?"; +"confirmation.dialog.title.delete" = "Are you sure to delete this item?"; +"confirmation.dialog.title.clear" = "삭제하시겠어요?"; +"confirmation.dialog.title.reset" = "초기화하시겠어요?"; +"confirmation.dialog.button.dropDatabase" = "Drop the database"; +"confirmation.dialog.button.remove" = "Remove"; +"confirmation.dialog.button.logout" = "로그아웃"; +"confirmation.dialog.button.delete" = "삭제"; +"confirmation.dialog.button.clear" = "삭제"; +"confirmation.dialog.button.reset" = "초기화"; + +// MARK: SubSection +"sub.section.button.showAll" = "모두 보기"; -// MARK: Common -"null" = "null"; -"expired" = "만료됨"; -"mystery" = "거절됨"; +// MARK: NewDawnView +"new.dawn.view.title.first" = "새로운 하루가 시작되었어요!"; +"new.dawn.view.title.second" = "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?"; +// Greeting +"struct.greeting.mark.start" = ""; +"struct.greeting.mark.separator" = ", "; +"struct.greeting.mark.and" = " 과 "; +"struct.greeting.mark.end" = "획득했어요!"; -// MARK: User -"favoriteNameByDev" = "즐겨찾기"; -"all_appendedByDev" = "모두"; +// MARK: HomeView +"home.view.title.home" = "홈"; +"home.view.section.title.frontpage" = "프론트 페이지"; +"home.view.section.title.toplists" = "상위 목록"; +"home.view.section.title.other" = "Other"; +// HomeMiscGridType +"home.misc.grid.type.title.popular" = "인기 작품"; +"home.misc.grid.type.title.watched" = "주시 태그"; +"home.misc.grid.type.title.history" = "읽은 목록"; + +// MARK: FrontpageView +"frontpage.view.title.frontpage" = "프론트 페이지"; + +// MARK: ToplistsView +"toplists.view.title.toplists" = "상위 목록"; + +// MARK: PopularView +"popular.view.title.popular" = "인기 작품"; + +// MARK: WatchedView +"watched.view.title.watched" = "주시 태그"; + +// MARK: HistoryView +"history.view.title.history" = "읽은 목록"; + +// MARK: FavoritesView +"favorites.view.title.favorites" = "즐겨찾기"; +// FavoriteCategory +"favorite.category.default" = "즐겨찾기 %@"; +"favorite.category.all" = "모두"; + +// MARK: SearchView +"search.view.title.search" = "검색"; +"search.view.section.title.recentlySearched" = "Recently searched"; +"search.view.section.title.recentlySeen" = "Recently seen"; +"search.view.section.title.quickSearch" = "빠른 검색"; +// Searchable prompt +"searchable.prompt.filter" = "Filter"; -// MARK: AlertView -"Loading..." = "로딩 중..."; -"Login" = "로그인"; -"There seems to be nothing here." = "여기가 아무도 없는 것 같습니다."; -"Retry" = "재시도"; -"A network error occurred." = "인터넷 접속 오류가 발생했어요."; -"A parsing error occurred." = "구분 분석 오류가 발생했어요."; -"An unknown error occurred." = "알 수 없는 오류가 발생했어요."; -"Please try again later." = "잠시 후 다시 시도해 주세요."; -"This gallery has been removed or is unavailable." = "이 갤러리는 제거되었거나 사용할 수 없어요."; -"This gallery is unavailable due to a copyright claim by PLACEHOLDER. Sorry about that." = "PLACEHOLDER의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; -"Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software." = "자동화된 미러링/수집 소프트웨어를 사용 중임을 나타내는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 금지되었습니다."; -"The ban expires in PLACEHOLDER." = "금지효과는 PLACEHOLDER에서 만료되었습니다."; -"BAN_INTERVAL_AND" = " and "; -"BAN_INTERVAL_DAYS" = " days"; -"BAN_INTERVAL_HOURS" = " hours"; -"BAN_INTERVAL_MINUTES" = " minutes"; -"BAN_INTERVAL_SECONDS" = " seconds"; -"Jump page" = "페이지 이동"; -"Confirm" = "확인"; +// MARK: QuickSearchView +"quick.search.view.title.quickSearch" = "빠른 검색"; +"quick.search.view.title.editWord" = "Edit word"; +"quick.search.view.title.newWord" = "New word"; +"quick.search.view.title.content" = "Content"; +"quick.search.view.title.name" = "Name"; +"quick.search.view.placeholder.optional" = "Optional"; +"quick.search.view.toolbar.item.button.confirm" = "확인"; -// MARK: HomeView -//"Clear history" = ""; +// MARK: SettingView +"setting.view.title.setting" = "설정"; +// SettingStateRoute +"enum.setting.state.route.value.account" = "계정"; +"enum.setting.state.route.value.general" = "일반"; +"enum.setting.state.route.value.appearance" = "외관"; +"enum.setting.state.route.value.reading" = "읽기"; +"enum.setting.state.route.value.laboratory" = "실험실"; +"enum.setting.state.route.value.ehPanda" = "EhPanda 정보"; + +// MARK: AccountSettingView +"account.setting.view.title.account" = "계정"; +"account.setting.view.title.showsNewDawnGreeting" = "새벽 인사 구독하기"; +"account.setting.view.button.login" = "로그인"; +"account.setting.view.button.logout" = "로그아웃"; +"account.setting.view.button.accountConfiguration" = "계정 설정"; +"account.setting.view.button.tagsManagement" = "태그 구독 관리"; +"account.setting.view.button.copyCookies" = "쿠키 복사하기"; +// CookieValue +"struct.cookie.value.localized.string.expired" = "만료됨"; +"struct.cookie.value.localized.string.mystery" = "거절됨"; +"struct.cookie.value.localized.string.none" = "None"; + +// MARK: LoginView +"login.view.title.login" = "로그인"; +"login.view.title.username" = "이름"; +"login.view.title.password" = "비밀번호"; + +// MARK: GeneralSettingView +"general.setting.view.title.general" = "일반"; +"general.setting.view.title.language" = "언어"; +"general.setting.view.title.autoLock" = "앱 자동 잠금"; +"general.setting.view.title.translatesTags" = "태그 번역하기"; +"general.setting.view.title.redirectsLinksToTheSelectedHost" = "선택한 서버로 이동하기"; +"general.setting.view.title.detectsLinksFromClipboard" = "클립보드의 링크 인식하기"; +"general.setting.view.title.backgroundBlurRadius" = "Background blur radius"; +"general.setting.view.button.logs" = "로그"; +"general.setting.view.button.importCustomTranslations" = "Import custom translations"; +"general.setting.view.button.removeCustomTranslations" = "Remove custom translations"; +"general.setting.view.button.clearImageCaches" = "이미지 캐시 지우기"; +"general.setting.view.value.defaultLanguageDescription" = "N/A"; +"general.setting.view.section.title.tagsTranslation" = "Tags translation"; +"general.setting.view.section.title.navigation" = "내비게이션"; +"general.setting.view.section.title.security" = "개인 정보 보호"; +"general.setting.view.section.title.caches" = "캐시"; +// AutoLockPolicy +"enum.auto.lock.policy.value.never" = "안 함"; +"enum.auto.lock.policy.value.instantly" = "즉시"; + +// MARK: LogsView +"logs.view.title.logs" = "로그"; +"logs.view.title.latest" = "마지막"; + +// MARK: AppearanceSettingView +"appearance.setting.view.title.appearance" = "외관"; +"appearance.setting.view.title.theme" = "테마"; +"appearance.setting.view.title.tintColor" = "액센트 색상"; +"appearance.setting.view.title.displayMode" = "표시방식"; +"appearance.setting.view.title.showsTagsInList" = "리스트에서 태그 보여주기"; +"appearance.setting.view.title.maximumNumberOfTags" = "태그 갯수"; +"appearance.setting.view.button.appIcon" = "앱 아이콘"; +"appearance.setting.view.menu.title.infite" = "제한 없음"; +"appearance.setting.view.section.title.list" = "리스트"; +// PerferredColorScheme +"enum.perferred.color.scheme.value.automatic" = "자동"; +"enum.perferred.color.scheme.value.light" = "라이트"; +"enum.perferred.color.scheme.value.dark" = "다크"; +// AppIconType +"enum.app.icon.type.value.default" = "기본"; +"enum.app.icon.type.value.ukiyoe" = "Ukiyo-e"; +// ListDisplayMode +"enum.display.mode.value.detail" = "자세히"; +"enum.display.mode.value.thumbnail" = "썸네일"; + +// MARK: AppIconView +"app.icon.view.title.appIcon" = "앱 아이콘"; + +// MARK: ReadingSettingView +"reading.setting.view.title.reading" = "읽기"; +"reading.setting.view.title.direction" = "방향"; +"reading.setting.view.title.preloadLimit" = "페이지 미리 로딩"; +"reading.setting.view.title.enablesLandscape" = "Enables landscape"; +"reading.setting.view.title.separatorHeight" = "페이지 간 여백 두께"; +"reading.setting.view.title.maximumScaleFactor" = "최대 확대 비율"; +"reading.setting.view.title.doubleTapScaleFactor" = "더블 탭 확대 비율"; +"reading.setting.view.section.title.appearance" = "외관"; +// ReadingDirection +"enum.reading.direction.value.vertical" = "위에서 아래로"; +"enum.reading.direction.value.rightToLeft" = "오른쪽에서 왼쪽으로"; +"enum.reading.direction.value.leftToRight" = "왼쪽에서 오른쪽으로"; + +// MARK: LaboratorySettingView +"laboratory.setting.view.title.laboratory" = "실험실"; +"laboratory.setting.view.title.bypassesSNIFiltering" = "SNI 차단 우회"; + +// MARK: EhPandaView +"ehpanda.view.title.ehPanda" = "EhPanda"; +"ehpanda.view.button.website" = "웹사이트"; +"ehpanda.view.button.altStoreSource" = "AltStore 소스"; +"ehpanda.view.description.version" = "버전"; +"ehpanda.view.section.title.specialThanks" = "Special thanks"; +"ehpanda.view.section.title.codeLevelContributors" = "Code-level contributors"; +"ehpanda.view.section.title.translationContributors" = "Translation contributors"; +"ehpanda.view.section.title.acknowledgements" = "도움을 주신 분들"; // MARK: DetailView -"Archive" = "아카이브"; -"Torrents" = "토렌트"; -"Share" = "공유"; -"Read" = "읽기"; -"DESC_SCROLL_ITEM_FAVORITED" = "즐겨찾기"; -"Times" = "번"; -"Language" = "언어"; -"%lld Ratings" = "%lld명의 별점"; -"Page Count" = "페이지 수"; -"Pages" = "페이지"; -"File Size" = "파일 크기"; -"Give a Rating" = "별점 주기"; -"Similar Gallery" = "비슷한 작품"; -"Preview" = "미리보기"; -"Comment" = "댓글"; -"Show All" = "모두 보기"; - -// MARK: ArchiveView -"N/A" = "무효"; -"Free" = "무료"; -"ARCHIVE_RESOLUTION_ORIGINAL" = "원본"; -"Download To Hath Client" = "Hath 클라이언트로 저장"; +"detail.view.button.read" = "읽기"; +"detail.view.button.postComment" = "평가 남기기"; +"detail.view.toolbar.item.button.archives" = "아카이브"; +"detail.view.toolbar.item.button.torrents" = "토렌트"; +"detail.view.toolbar.item.button.share" = "공유"; +"detail.view.scroll.section.title.favorited" = "즐겨찾기"; +"detail.view.scroll.section.title.language" = "언어"; +"detail.view.scroll.section.title.ratings" = "%@명의 별점"; +"detail.view.scroll.section.title.pageCount" = "페이지 수"; +"detail.view.scroll.section.title.fileSize" = "파일 크기"; +"detail.view.scroll.section.description.favorited" = "번"; +"detail.view.scroll.section.description.pageCount" = "페이지"; +"detail.view.action.section.button.giveARating" = "별점 주기"; +"detail.view.action.section.button.similarGallery" = "비슷한 작품"; +"detail.view.section.title.previews" = "미리보기"; +"detail.view.section.title.comments" = "댓글"; + +// MARK: ArchivesView +"archives.view.title.archives" = "아카이브"; +"archives.view.button.downloadToHathClient" = "H@H 클라이언트로 저장"; +// HathArchive +"struct.hath.archive.price.value.free" = "무료"; +"struct.hath.archive.price.value.notAvailable" = "무효"; +"struct.hath.archive.resolution.value.original" = "원본"; + +// MARK: TorrentsView +"torrents.view.title.torrents" = "토렌트"; // MARK: GalleryInfosView -"Gallery infos" = "갤러리 정보"; -"Title" = "제목"; -"Japanese title" = "일본어 제목"; -"Gallery URL" = "갤러리 주소"; -"Cover URL" = "표지 주소"; -"Archive URL" = "아카이브 주소"; -"Torrent URL" = "토렌트 주소"; -"Parent URL" = "부모 갤러리 링크"; -"Category" = "장르"; -"Uploader" = "업로드"; -"Posted date" = "업로드된 날짜"; -"Visible" = "가시성"; -"Page count" = "페이지 수"; -"File size" = "파일 크기"; -"Favorited times" = "즐겨찾기된 수"; -"Favorited" = "즐겨찾기에 저장 됨"; -"Rating count" = "별점 갯수"; -"Average rating" = "평균 별점"; -"User rating" = "유저 별점"; -"Torrent count" = "토렌트 수"; -"Yes" = "네"; -"No" = "아니요"; -"Expunged" = "삭제됨"; - -// MARK: CommentView -"Post Comment" = "평가 남기기"; -"Edit Comment" = "평가 수정"; -"Cancel" = "취소"; -"Post" = "등록"; +"gallery.infos.view.title.galleryInfos" = "갤러리 정보"; +"gallery.infos.view.title.ID" = "ID"; +"gallery.infos.view.title.token" = "Token"; +"gallery.infos.view.title.title" = "제목"; +"gallery.infos.view.title.japaneseTitle" = "일본어 제목"; +"gallery.infos.view.title.galleryURL" = "갤러리 주소"; +"gallery.infos.view.title.coverURL" = "표지 주소"; +"gallery.infos.view.title.archiveURL" = "아카이브 주소"; +"gallery.infos.view.title.torrentURL" = "토렌트 주소"; +"gallery.infos.view.title.parentURL" = "부모 갤러리 링크"; +"gallery.infos.view.title.category" = "장르"; +"gallery.infos.view.title.uploader" = "업로드"; +"gallery.infos.view.title.postedDate" = "업로드된 날짜"; +"gallery.infos.view.title.visibility" = "가시성"; +"gallery.infos.view.title.language" = "언어"; +"gallery.infos.view.title.pageCount" = "페이지 수"; +"gallery.infos.view.title.fileSize" = "파일 크기"; +"gallery.infos.view.title.favoritedTimes" = "즐겨찾기된 수"; +"gallery.infos.view.title.favorited" = "즐겨찾기에 저장 됨"; +"gallery.infos.view.title.ratingCount" = "별점 갯수"; +"gallery.infos.view.title.averageRating" = "평균 별점"; +"gallery.infos.view.title.myRating" = "My rating"; +"gallery.infos.view.title.torrentCount" = "토렌트 수"; +"gallery.infos.view.value.none" = "None"; +"gallery.infos.view.value.yes" = "네"; +"gallery.infos.view.value.no" = "아니요"; +// GalleryVisibility +"gallery.visibility.value.yes" = "네"; +"gallery.visibility.value.no" = "아니요 (%@)"; +"gallery.visibility.value.no.reason.expunged" = "삭제됨"; + +// MARK: CommentsView +"comments.view.title.comments" = "댓글"; + +// MARK: PostCommentView +"post.comment.view.title.postComment" = "평가 남기기"; +"post.comment.view.title.editComment" = "평가 수정"; +"post.comment.view.button.cancel" = "취소"; +"post.comment.view.button.post" = "등록"; + +// MARK: PreviewsView +"previews.view.title.previews" = "미리보기"; // MARK: ReadingView -"AutoPlay" = "자동 재생"; -"Reload" = "재시도"; -"Copy" = "복사"; -"Save" = "저장"; -//"Save original" = ""; -"Saved to photo library" = "이미지 저장"; - -// MARK: SettingView -"Setting" = "설정"; -"Account" = "계정"; -"Gallery" = "갤러리"; -"Login" = "로그인"; -"Username" = "이름"; -"Password" = "비밀번호"; -"Logout" = "로그아웃"; -"Are you sure to logout?" = "로그아웃 하시겠어요?"; -"Account configuration" = "계정 설정"; -"Manage tags subscription" = "태그 구독 관리"; -"Copy cookies" = "쿠키 복사하기"; - -"General" = "일반"; -"Navigation" = "내비게이션"; -"Redirects links to the selected host" = "선택한 서버로 이동하기"; -"Detects links from the clipboard" = "클립보드의 링크 인식하기"; -"Security" = "개인 정보 보호"; -"Auto-Lock" = "앱 자동 잠금"; -"App switcher blur" = "앱 안 쓸 때 자동으로 흐려지게 만들기"; -"Cache" = "캐시"; -"Clear" = "삭제"; -"Are you sure to clear?" = "삭제하시겠어요?"; -"Clear image caches" = "이미지 캐시 지우기"; - -"Appearance" = "외관"; -"Global" = "전체"; -"Theme" = "테마"; -"Tint Color" = "액센트 색상"; -"App Icon" = "앱 아이콘"; -"Translates tags" = "태그 번역하기"; -"List" = "리스트"; -"Display mode" = "표시방식"; -"LIST_DISPLAY_MODE_DETAIL" = "자세히"; -"LIST_DISPLAY_MODE_THUMBNAIL" = "썸네일"; -"Shows tags in list" = "리스트에서 태그 보여주기"; -"Maximum number of tags" = "태그 갯수"; -"Infinity" = "제한 없음"; - -"Reading" = "읽기"; -"Direction" = "방향"; -"READING_DIRECTION_VERTICAL" = "위에서 아래로"; -"Right-to-left" = "오른쪽에서 왼쪽으로"; -"Left-to-right" = "왼쪽에서 오른쪽으로"; -"Preload limit" = "페이지 미리 로딩"; -"%lld pages" = "%lld페이지"; -"%lld times" = "%lld번"; -"Prefers landscape" = "웬만하면 가로 화면으로"; -"Separator height" = "페이지 간 여백 두께"; -"Maximum scale factor" = "최대 확대 비율"; -"Double tap scale factor" = "더블 탭 확대 비율"; -"Dual-page mode" = "두 장을 한 화면으로 보기"; -"Except the cover" = "표지 제외하기"; - -"Laboratory" = "실험실"; -"Bypass SNI Filtering" = "SNI 차단 우회"; - -"About EhPanda" = "EhPanda 정보"; -"Version" = "버전"; -"Website" = "웹사이트"; -"AltStore Source" = "AltStore 소스"; -"Acknowledgement" = "도움을 주신 분들"; +"reading.view.context.menu.button.reload" = "재시도"; +"reading.view.context.menu.button.copy" = "복사"; +"reading.view.context.menu.button.save" = "저장"; +"reading.view.context.menu.button.saveOriginal" = "Save original"; +"reading.view.context.menu.button.share" = "공유"; +"reading.view.toolbar.item.title.autoPlay" = "자동 재생"; +"reading.view.toolbar.item.title.dualPageMode" = "두 장을 한 화면으로 보기"; +"reading.view.toolbar.item.title.exceptTheCover" = "표지 제외하기"; +// AutoPlayPolicy +"enum.auto.play.policy.value.off" = "Off"; + +// MARK: FiltersView +"filters.view.title.filters" = "필터"; +"filters.view.title.advancedSettings" = "고급 설정"; +"filters.view.title.searchGalleryName" = "갤러리 이름을 찾아보기"; +"filters.view.title.searchGalleryTags" = "갤러리 태그를 찾아보기"; +"filters.view.title.searchGalleryDescription" = "갤러리 설명을 찾아보기"; +"filters.view.title.searchTorrentFilenames" = "토렌트 파일 이름을 찾아보기"; +"filters.view.title.onlyShowGalleriesWithTorrents" = "토렌트 있는 갤러리만 보이기"; +"filters.view.title.searchLowPowerTags" = "인기가 없는 태그를 찾아보기"; +"filters.view.title.searchDownvotedTags" = "낮은 평가의 태그를 찾아보기"; +"filters.view.title.showExpungedGalleries" = "삭제된 갤러리를 보여주기"; +"filters.view.title.setMinimumRating" = "최소 별점 설정하기"; +"filters.view.title.minimumRating" = "최소 별점"; +"filters.view.title.setPagesRange" = "페이지 범위 설정"; +"filters.view.title.pagesRange" = "페이지 범위"; +"filters.view.title.disableLanguageFilter" = "언어 필터 끄기"; +"filters.view.title.disableUploaderFilter" = "업로더 필터 끄기"; +"filters.view.title.disableTagsFilter" = "태그 필터 끄기"; +"filters.view.button.resetFilters" = "모든 필터 초기화"; +"filters.view.section.title.advanced" = "고급"; +"filters.view.section.title.defaultFilter" = "기본 옵션"; +// FilterRange +"enum.filter.range.value.search" = "검색"; +"enum.filter.range.value.global" = "전체"; +"enum.filter.range.value.watched" = "주시 태그"; // MARK: EhSettingView -"Profile Settings" = "프로필 설정"; -"Selected profile" = "선택한 프로필"; -"Set as default" = "기본으로 설정"; -"Delete profile" = "프로필 삭제"; -"Are you sure to delete this profile?" = "정말 이 프로필을 삭제하시겠어요?"; -"Delete" = "삭제"; -"Rename" = "이름 변경"; -"Create new" = "추가"; - -"Image Load Settings" = "이미지 로드 설정"; -"Recommended." = "추천"; -"Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports." = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; -"Donator only. You will not be able to browse as many pages, enable only if having severe problems." = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; -"Load images through the Hath network" = "Hath 네트워크를 통하여 이미지 로드"; -"Any client" = "어떤 클라이언트에서든"; -"Default port clients only" = "기본 포트 클라이언트만"; -"LOAD_THROUGH_HATH_NO" = "아닙니다"; -"You appear to be browsing the site from **PLACEHOLDER** or use a VPN or proxy in this country, which means the site will try to load images from Hath clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below." = "**PLACEHOLDER**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 Hath 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요."; -"Browsing country" = "브라우징하는 나라"; - -"Image Size Settings" = "이미지 사이즈 설정"; -"Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000." = "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요."; -"Image resolution" = "이미지 해상도"; -"Auto" = "자동"; -"While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)" = "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)"; -"Image size" = "이미지 사이즈"; -"Horizontal" = "가로"; -"Vertical" = "세로"; - -"Gallery Name Display" = "갤러리 이름 보이기"; -"Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?" = "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요."; -"Gallery name" = "갤러리 이름"; -"Default Title" = "영어 제목"; -"Japanese Title (if available)" = "일본어 제목(가능하면)"; - -"Archiver Settings" = "아카이버"; -"The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here." = "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다. "; -"Archiver behavior" = "아카이버 동작 방법 설정"; -"Manual Select, Manual Start (Default)" = "수동 선택, 수동 시작 (기본)"; -"Manual Select, Auto Start" = "수동 선택, 자동 시작"; -"Auto Select Original, Manual Start" = "자동으로 원본을 선택, 수동 시작"; -"Auto Select Original, Auto Start" = "자동으로 원본을 선택, 자동 시작"; -"Auto Select Resample, Manual Start" = "자동으로 저화질을 선택, 수동 시작"; -"Auto Select Resample, Auto Start" = "자동으로 저화질을 선택, 자동 시작"; - -"Front Page Settings" = "프론트 페이지 설정"; -"Which display mode would you like to use on the front and search pages?" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; -"Compact" = "Compact"; -"Thumbnail" = "Thumbnail"; -"Extended" = "Extended"; -"Minimal" = "Minimal"; -"Minimal+" = "Minimal+"; -"What categories would you like to show by default on the front page and in searches?" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; - -"Here you can choose and rename your favorite categories." = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; -"You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting." = "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요."; -"Favorites sort order" = "관심 순서를 배열"; -"By last gallery update time" = "마지막 업데이트 시간으로"; -"By favorited time" = "별점 시간으로"; - -"Ratings" = "별점"; -"By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works." = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; -"Ratings color" = "별점 색깔"; - -"Tag Namespaces" = "태그 네임스페이스"; -"If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces." = "기본 태그 검색에서 특정 네임스페이스를 제외하려면 아래 네임스페이스들을 확인해주세요. 이렇게 해도 이러한 네임스페이스에 태그가 있는 갤러리가 나타나지 않고 태그를 검색할 때 해당 네임스페이스가 표시되지 않도록 할 수 있어요."; - -"Tag Filtering Threshold" = "태그 필터링 임계값"; -"You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999." = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; - -"Tag Watching Threshold" = "태그 보여주기 임계값"; -"Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999." = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; - -"Excluded Languages" = "제외된 언어"; -"If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query." = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; -"Original" = "원본"; -"Translated" = "번역됨"; -"Rewrite" = "다시 쓰기"; - -"Excluded Uploaders" = "제외된 업로드"; -"If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query." = "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요."; -"You are currently using **%lld / 1000** exclusion slots." = "**%lld / 1000** 개의 슬롯을 사용하고 있어요."; - -"Search Result Count" = "검색 결과 수"; -"How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)" = "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)"; -"Result count" = "결과 수"; - -"Thumbnail Settings" = "썸네일 설정"; -"How would you like the mouse-over thumbnails on the front page to load when using List Mode?" = "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?"; -"Pages load faster, but there may be a slight delay before a thumb appears." = "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요."; -"Pages take longer to load, but there is no delay for loading a thumb after the page has loaded." = "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요."; -"Thumbnail load timing" = "썸네일 로드 시간"; -"On mouse-over" = "마우스를 올릴 때"; -"On page load" = "페이지 로드될 때"; -"You can set a default thumbnail configuration for all galleries you visit." = "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요."; -"Size" = "사이즈"; -"Large" = "크게"; -"Rows" = "줄"; - -"Thumbnail Scaling" = "썸네일 크기"; -"Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%." = "썸네일 그림 및 확장된 갤러리 목록 보기의 미리 보기는 75%에서 150% 사이의 사용자 지정 값으로 조정할 수 있어요."; -"Scale factor" = "크기 비율"; - -"Viewport Override" = "뷰포트 조정"; -"Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400." = "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100% 썸네일 스케일의 추천 값은 640에서 1400 사이에요."; -"Virtual width" = "가상 너비"; - -"Gallery Comments" = "갤러리 댓글"; -"Comments sort order" = "댓글 순서 "; -"Oldest comments first" = "가장 이른 순서"; -"Recent comments first" = "최신순"; -"By highest score" = "평가가 가장 높은 순서"; -"Comment votes show timing" = "평가의 시간을 보이기"; -"On score hover or click" = "점수를 가리키커나 클리하기"; -"Always" = "항상"; - -"Gallery Tags" = "갤러리 태그"; -"Tags sort order" = "태그 순서를 배열"; -"Alphabetical" = "알파벳순으로"; -"By tag power" = "태크 가중치로"; - -"Gallery Page Numbering" = "갤러리 페이지 번호 매기기"; -"Show gallery page numbers" = "갤러리 페이지 번호 보이기"; - -"Hath Local Network Host" = "Hath 로컬 네트워크 호스트"; -"This setting can be used if you have a Hath client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work." = "이 설정은 사이트를 검색하는 것과 동일한 공용 IP로 로컬 네트워크에서 Hath 클라이언트를 실행하는 경우 사용할 수 있습니다. 일부 라우터는 버그가 있어 요청을 자신의 IP로 다시 라우팅할 수 없기에, 아래를 따라서 이 문제를 해결할 수 있습니다.\n찾아보는 동일한 장치에서 클라이언트를 실행하는 경우 루프백 주소(127.0.0.1:port)를 사용할 수 있습니다. 클라이언트가 네트워크의 다른 장치에서 실행 중인 경우 로컬 네트워크 IP를 사용할 수 있습니다. 일부 브라우저 구성에서는 외부 웹 사이트가 로컬 네트워크 IP가 있는 URL에 액세스할 수 없도록 합니다. 그런 다음 사이트가 작동하려면 사이트를 화이트리스트에 추가해야 합니다."; -"IP address:Port" = "IP주소: 포트"; - -"Original Images" = "원본 이미지"; -"Use original images" = "원본 뷰어 적용"; -"Multi-Page Viewer" = "멀티 페이지 뷰어"; -"Use Multi-Page Viewer" = "다중 페이지 뷰어 적용"; -"Display style" = "보여주기 스타일"; -"Align left, scale if overwidth" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; -"Align center, scale if overwidth" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; -"Align center, always scale" = "가운데 정렬, 항상 크기 맞추기"; -"Show thumbnail pane" = "썸네일 창 표시"; - -// MARK: LogsView -"Logs" = "로그"; -"Latest" = "마지막"; -"%lld records" = "%lld 기록수"; - -// MARK: FilterView -"Filters" = "필터"; -"Basic" = "일반"; -"Reset filters" = "모든 필터 초기화"; -"Are you sure to reset?" = "초기화하시겠어요?"; -"Reset" = "초기화"; -"Advanced settings" = "고급 설정"; -"Advanced" = "고급"; -"Search gallery name" = "갤러리 이름을 찾아보기"; -"Search gallery tags" = "갤러리 태그를 찾아보기"; -"Search gallery description" = "갤러리 설명을 찾아보기"; -"Search torrent filenames" = "토렌트 파일 이름을 찾아보기"; -"Only show galleries with torrents" = "토렌트 있는 갤러리만 보이기"; -"Search Low-Power tags" = "인기가 없는 태그를 찾아보기"; -"Search downvoted tags" = "낮은 평가의 태그를 찾아보기"; -"Show expunged galleries" = "삭제된 갤러리를 보여주기"; -"Set minimum rating" = "최소 별점 설정하기"; -"Minimum rating" = "최소 별점"; -"%lld stars" = "%lld별"; -"Set pages range" = "페이지 범위 설정"; -"Pages range" = "페이지 범위"; -"Default Filter" = "기본 옵션"; -"Disable language filter" = "언어 필터 끄기"; -"Disable uploader filter" = "업로더 필터 끄기"; -"Disable tags filter" = "태그 필터 끄기"; - -// MARK: NewDawnView -"Show new dawn greeting" = "새벽 인사 구독하기"; -"It is the dawn of a new day!" = "새로운 하루가 시작되었어요!"; -"Reflecting on your journey so far, you find that you are a little wiser." = "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?"; -"GAINCONTENT_START" = ""; -"GAINCONTENT_SEPARATOR" = ","; -"GAINCONTENT_AND" = "과"; -"GAINCONTENT_END" = "획득했어요!"; - -// MARK: QuickSearchView -"Quick search" = "빠른 검색"; -//"Alias" = ""; - -// MARK: HomeListType -"Search" = "검색"; -"Frontpage" = "프론트"; -"Popular" = "인기 작품"; -"Watched" = "주시 태그"; -"Favorites" = "즐겨찾기"; -"Toplists" = "상위 목록"; -"Downloaded" = "다운로드"; -"History" = "읽은 목록"; - -// MARK: ToplistType -"All time" = "전체"; -"Past year" = "지난 해"; -"Past month" = "지난 달"; -"Yesterday" = "어제"; +"eh.setting.view.title.hostSetting" = "%@ 설정"; +"eh.setting.view.section.title.profileSettings" = "프로필 설정"; +"eh.setting.view.title.selectedProfile" = "선택한 프로필"; +"eh.setting.view.button.setAsDefault" = "기본으로 설정"; +"eh.setting.view.button.deleteProfile" = "프로필 삭제"; +"eh.setting.view.button.rename" = "이름 변경"; +"eh.setting.view.button.createNew" = "추가"; +"eh.setting.view.toolbar.item.button.done" = "Done"; + +"eh.setting.view.section.title.imageLoadSettings" = "이미지 로드 설정"; +"eh.setting.view.title.loadImagesThroughTheHathNetwork" = "Hath 네트워크를 통하여 이미지 로드"; +"eh.setting.view.title.browsingCountry" = "브라우징하는 나라"; +"eh.setting.view.description.browsingCountry" = "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요."; +// EhSetting.LoadThroughHathSetting +"enum.eh.setting.load.through.hath.setting.value.anyClient" = "어떤 클라이언트에서든"; +"enum.eh.setting.load.through.hath.setting.value.defaultPortOnly" = "기본 포트 클라이언트만"; +"enum.eh.setting.load.through.hath.setting.value.modernNo" = "아닙니다 [Modern/HTTPS]"; +"enum.eh.setting.load.through.hath.setting.value.legacyNo" = "아닙니다 [Legacy/HTTP]"; +"enum.eh.setting.load.through.hath.setting.description.anyClient" = "추천."; +"enum.eh.setting.load.through.hath.setting.description.defaultPortOnly" = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; +"enum.eh.setting.load.through.hath.setting.description.modernNo" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; +"enum.eh.setting.load.through.hath.setting.description.legacyNo" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; + +"eh.setting.view.section.title.imageSizeSettings" = "이미지 사이즈 설정"; +"eh.setting.view.title.imageResolution" = "이미지 해상도"; +"eh.setting.view.description.imageResolution" = "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요."; +"eh.setting.view.title.imageSize" = "이미지 사이즈"; +"eh.setting.view.description.imageSize" = "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)"; +"eh.setting.view.title.horizontal" = "가로"; +"eh.setting.view.title.vertical" = "세로"; +// EhSetting.ImageResolution +"enum.eh.setting.image.resolution.value.auto" = "자동"; + +"eh.setting.view.section.title.galleryNameDisplay" = "갤러리 이름 보이기"; +"eh.setting.view.title.galleryName" = "갤러리 이름"; +"eh.setting.view.description.galleryName" = "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요."; +// EhSetting.GalleryName +"enum.eh.setting.gallery.name.value.default" = "영어 제목"; +"enum.eh.setting.gallery.name.value.japanese" = "일본어 제목(가능하면)"; + +"eh.setting.view.section.title.archiverSettings" = "아카이버"; +"eh.setting.view.title.archiverBehavior" = "아카이버 동작 방법 설정"; +"eh.setting.view.description.archiverBehavior" = "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다."; +// EhSetting.ArchiverBehavior +"enum.eh.setting.archiver.behavior.value.manualSelectManualStart" = "수동 선택, 수동 시작 (기본)"; +"enum.eh.setting.archiver.behavior.value.manualSelectAutoStart" = "수동 선택, 자동 시작"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalManualStart" = "자동으로 원본을 선택, 수동 시작"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalAutoStart" = "자동으로 원본을 선택, 자동 시작"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleManualStart" = "자동으로 저화질을 선택, 수동 시작"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleAutoStart" = "자동으로 저화질을 선택, 자동 시작"; + +"eh.setting.view.section.title.frontPageSettings" = "프론트 페이지 설정"; +"eh.setting.view.title.displayMode" = "표시방식"; +"eh.setting.view.description.displayMode" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; +"eh.setting.view.description.galleryCategory" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; +// EhSetting.DisplayMode +"enum.eh.setting.display.mode.value.compact" = "Compact"; +"enum.eh.setting.display.mode.value.thumbnail" = "Thumbnail"; +"enum.eh.setting.display.mode.value.extended" = "Extended"; +"enum.eh.setting.display.mode.value.minimal" = "Minimal"; +"enum.eh.setting.display.mode.value.minimalPlus" = "Minimal+"; + +"eh.setting.view.section.title.favorites" = "즐겨찾기"; +"eh.setting.view.description.favoriteCategories" = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; +"eh.setting.view.title.favoritesSortOrder" = "관심 순서를 배열"; +"eh.setting.view.description.favoritesSortOrder" = "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요."; +// EhSetting.FavoritesSortOrder +"enum.eh.setting.favorites.sort.order.value.lastUpdateTime" = "마지막 업데이트 시간으로"; +"enum.eh.setting.favorites.sort.order.value.favoritedTime" = "별점 시간으로"; + +"eh.setting.view.section.title.ratings" = "별점"; +"eh.setting.view.title.ratingsColor" = "별점 색깔"; +"eh.setting.view.promt.ratingsColor" = "RRGGB"; +"eh.setting.view.description.ratingsColor" = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; + +"eh.setting.view.section.title.tagsNamespaces" = "태그 네임스페이스"; +"eh.setting.view.description.tagsNamespaces" = "기본 태그 검색에서 특정 네임스페이스를 제외하려면 아래 네임스페이스들을 확인해주세요. 이렇게 해도 이러한 네임스페이스에 태그가 있는 갤러리가 나타나지 않고 태그를 검색할 때 해당 네임스페이스가 표시되지 않도록 할 수 있어요."; + +"eh.setting.view.section.title.tagFilteringThreshold" = "태그 필터링 임계값"; +"eh.setting.view.title.tagFilteringThreshold" = "태그 필터링 임계값"; +"eh.setting.view.description.tagFilteringThreshold" = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; + +"eh.setting.view.section.title.tagWatchingThreshold" = "태그 보여주기 임계값"; +"eh.setting.view.title.tagWatchingThreshold" = "태그 보여주기 임계값"; +"eh.setting.view.description.tagWatchingThreshold" = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; + +"eh.setting.view.section.title.excludedLanguages" = "제외된 언어"; +"eh.setting.view.description.excludedLanguages" = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; +// EhSetting.ExcludedLanguagesCategory +"enum.eh.setting.excluded.languages.category.value.original" = "원본"; +"enum.eh.setting.excluded.languages.category.value.translated" = "번역됨"; +"enum.eh.setting.excluded.languages.category.value.rewrite" = "다시 쓰기"; + +"eh.setting.view.section.title.excludedUploaders" = "제외된 업로드"; +"eh.setting.view.description.excludedUploaders" = "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요."; +"eh.setting.view.description.excludedUploadersCount" = "**%@ / %@** 개의 슬롯을 사용하고 있어요."; + +"eh.setting.view.section.title.searchResultCount" = "검색 결과 수"; +"eh.setting.view.title.resultCount" = "결과 수"; +"eh.setting.view.description.resultCount" = "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)"; + +"eh.setting.view.section.title.thumbnailSettings" = "썸네일 설정"; +"eh.setting.view.title.thumbnailLoadTiming" = "썸네일 로드 시간"; +"eh.setting.view.description.thumbnailLoadTiming" = "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?"; +"eh.setting.view.description.thumbnailConfiguration" = "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요."; +"eh.setting.view.title.thumbnailSize" = "사이즈"; +"eh.setting.view.title.thumbnailRowCount" = "줄"; +// EhSetting.ThumbnailLoadTiming +"enum.eh.setting.thumbnail.load.timing.value.onMouseOver" = "마우스를 올릴 때"; +"enum.eh.setting.thumbnail.load.timing.value.onPageLoad" = "페이지 로드될 때"; +"enum.eh.setting.thumbnail.load.timing.description.onMouseOver" = "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요."; +"enum.eh.setting.thumbnail.load.timing.description.onPageLoad" = "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요."; +// EhSetting.ThumbnailSize +"enum.eh.setting.thumbnail.size.value.normal" = "보통"; +"enum.eh.setting.thumbnail.size.value.large" = "크게"; + +"eh.setting.view.section.title.thumbnailScaling" = "썸네일 크기"; +"eh.setting.view.title.scaleFactor" = "크기 비율"; +"eh.setting.view.description.scaleFactor" = "썸네일 그림 및 확장된 갤러리 목록 보기의 미리 보기는 75%에서 150% 사이의 사용자 지정 값으로 조정할 수 있어요."; + +"eh.setting.view.section.title.viewportOverride" = "뷰포트 조정"; +"eh.setting.view.title.virtualWidth" = "가상 너비"; +"eh.setting.view.description.virtualWidth" = "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100% 썸네일 스케일의 추천 값은 640에서 1400 사이에요."; + +"eh.setting.view.section.title.galleryComments" = "갤러리 댓글"; +"eh.setting.view.title.commentsSortOrder" = "댓글 순서"; +"eh.setting.view.title.commentsVotesShowTiming" = "평가의 시간을 보이기"; +// EhSetting.CommentsSortOrder +"enum.eh.setting.comments.sort.order.value.oldest" = "가장 이른 순서"; +"enum.eh.setting.comments.sort.order.value.recent" = "최신순"; +"enum.eh.setting.comments.sort.order.value.highestScore" = "평가가 가장 높은 순서"; +// EhSetting.CommentVotesShowTiming +"enum.eh.setting.comments.votes.show.timing.value.onHoverOrClick" = "점수를 가리키커나 클리하기"; +"enum.eh.setting.comments.votes.show.timing.value.always" = "항상"; + +"eh.setting.view.section.title.galleryTags" = "갤러리 태그"; +"eh.setting.view.title.tagsSortOrder" = "태그 순서를 배열"; +// EhSetting.TagsSortOrder +"enum.eh.setting.tags.sort.order.value.alphabetical" = "알파벳순으로"; +"enum.eh.setting.tags.sort.order.value.tagPower" = "태크 가중치로"; + +"eh.setting.view.section.title.galleryPageNumbering" = "갤러리 페이지 번호 매기기"; +"eh.setting.view.title.showGalleryPageNumbers" = "갤러리 페이지 번호 보이기"; + +"eh.setting.view.section.title.hathLocalNetworkHost" = "Hath 로컬 네트워크 호스트"; +"eh.setting.view.title.ipAddressPort" = "IP주소:포트"; +"eh.setting.view.description.ipAddressPort" = "이 설정은 사이트를 검색하는 것과 동일한 공용 IP로 로컬 네트워크에서 H@H 클라이언트를 실행하는 경우 사용할 수 있습니다. 일부 라우터는 버그가 있어 요청을 자신의 IP로 다시 라우팅할 수 없기에, 아래를 따라서 이 문제를 해결할 수 있습니다.\n찾아보는 동일한 장치에서 클라이언트를 실행하는 경우 루프백 주소(127.0.0.1:port)를 사용할 수 있습니다. 클라이언트가 네트워크의 다른 장치에서 실행 중인 경우 로컬 네트워크 IP를 사용할 수 있습니다. 일부 브라우저 구성에서는 외부 웹 사이트가 로컬 네트워크 IP가 있는 URL에 액세스할 수 없도록 합니다. 그런 다음 사이트가 작동하려면 사이트를 화이트리스트에 추가해야 합니다."; + +"eh.setting.view.section.title.originalImages" = "원본 이미지"; +"eh.setting.view.title.useOriginalImages" = "원본 뷰어 적용"; + +"eh.setting.view.section.title.multiPageViewer" = "멀티 페이지 뷰어"; +"eh.setting.view.title.useMultiPageViewer" = "다중 페이지 뷰어 적용"; +"eh.setting.view.title.displayStyle" = "보여주기 스타일"; +"eh.setting.view.title.showThumbnailPane" = "썸네일 창 표시"; +// EhSetting.MultiplePageViewerStyle +"enum.eh.setting.multiple.page.viewer.style.value.alignLeftScaleIfOverWidth" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterScaleIfOverWidth" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterAlwaysScale" = "가운데 정렬, 항상 크기 맞추기"; // MARK: Category -"Doujinshi" = "동인지"; -"Manga" = "만화"; -"Artist CG" = "일러스트"; -"Game CG" = "게임 CG"; -"Western" = "서양"; -"Non-H" = "Non-H"; -"Image Set" = "포토북"; -"Cosplay" = "코스프레"; -"Asian Porn" = "Asian Porn"; -"Misc" = "기타"; -//"Private" = ""; +"enum.category.value.doujinshi" = "동인지"; +"enum.category.value.manga" = "만화"; +"enum.category.value.artistCG" = "일러스트"; +"enum.category.value.gameCG" = "게임 CG"; +"enum.category.value.western" = "서양"; +"enum.category.value.nonH" = "Non-H"; +"enum.category.value.imageSet" = "포토북"; +"enum.category.value.cosplay" = "코스프레"; +"enum.category.value.asianPorn" = "Asian Porn"; +"enum.category.value.misc" = "기타"; +"enum.category.value.private" = "Private"; // MARK: TagCategory -"Reclass" = "재분류"; -"Language" = "언어"; -"Parody" = "원작"; -"Character" = "캐릭터"; -"Group" = "그룹"; -"Artist" = "작가"; -"Male" = "남성"; -"Female" = "여성"; -//"Mixed" = ""; -//"Cosplayer" = ""; -//"Other" = ""; -//"Temp" = ""; - -// MARK: IconType -"Normal" = "보통"; -"Default" = "기본"; -"Weird" = "무서움"; - -// MARK: PreferredColorScheme -"Automatic" = "자동"; -"Light" = "라이트"; -"Dark" = "다크"; - -// MARK: AutoLockPolicy -"Never" = "안 함"; -"Instantly" = "즉시"; -"%lld seconds" = "%lld초"; -"%lld minute" = "%lld분"; -"%lld minutes" = "%lld분"; +"enum.tag.category.value.reclass" = "재분류"; +"enum.tag.category.value.language" = "언어"; +"enum.tag.category.value.parody" = "원작"; +"enum.tag.category.value.character" = "캐릭터"; +"enum.tag.category.value.group" = "그룹"; +"enum.tag.category.value.artist" = "작가"; +"enum.tag.category.value.male" = "남성"; +"enum.tag.category.value.female" = "여성"; +"enum.tag.category.value.mixed" = "Mixed"; +"enum.tag.category.value.cosplayer" = "Cosplayer"; +"enum.tag.category.value.other" = "Other"; +"enum.tag.category.value.temp" = "Temp"; // MARK: Language -"LANGUAGE_OTHER" = "기타"; -"LANGUAGE_INVALID" = "N/A"; - -"Afrikaans" = "아프리칸스어"; "Albanian" = "알바니아어"; "Arabic" = "아랍어"; - -"Bengali" = "벵갈어"; "Bosnian" = "보스니아어"; "Bulgarian" = "불가리아어"; "Burmese" = "버마어"; - -"Catalan" = "카탈루냐어"; "Cebuano" = "세부어"; "Chinese" = "중국어"; "Croatian" = "크로아티아어"; "Czech" = "체코어"; - -"Danish" = "덴마크어"; "Dutch" = "네덜란드어"; - -"English" = "영어"; "Esperanto" = "국제어"; "Estonian" = "에스토니아어"; - -"Finnish" = "핀란드어"; "French" = "프랑스어"; - -"Georgian" = "그루지야어"; "German" = "독일어"; "Greek" = "그리스어"; - -"Hebrew" = "히브리어"; "Hindi" = "힌디어"; "Hmong" = "묘어"; "Hungarian" = "헝가리어"; - -"Indonesian" = "인도네시아어"; "Italian" = "이탈리아어"; - -"Japanese" = "일본어"; - -"Kazakh" = "카자흐어"; "Khmer" = "크메르원"; "Korean" = "한국어"; "Kurdish" = "쿠르드어"; - -"Lao" = "라오스어"; "Latin" = "라틴어"; - -"Mongolian" = "몽골어"; - -"Ndebele" = "은데벨리어"; "Nepali" = "네팔어"; "Norwegian" = "노르웨이어로"; - -"Oromo" = "오로모어"; - -"Pashto" = "파슈토어"; "Persian" = "페르시아어"; "Polish" = "폴란드어"; "Portuguese" = "포르투갈어"; "Punjabi" = "펀자브어"; - -"Romanian" = "루마니아어"; "Russian" = "러시아어"; - -"Sango" = "쌍고어"; "Serbian" = "세르비아어"; "Shona" = "쇼나어"; "Slovak" = "슬로바키아어"; "Slovenian" = "슬로베니아어"; "Somali" = "소말리아어"; "Spanish" = "스페인어"; "Swahili" = "스와히리어로"; "Swedish" = "스웨덴어"; - -"Tagalog" = "타갈로어"; "Thai" = "타이어"; "Tigrinya" = "티글리니아어"; "Turkish" = "터키어"; - -"Ukrainian" = "우크라이나어"; "Urdu" = "우르두어"; - -"Vietnamese" = "베트남어"; - -"Zulu" = "줄루어"; - -// MARK: EhSettingCountry -"Auto-Detect" = "자동으로 설정"; "Afghanistan" = "아프가니스탄"; "Aland Islands" = "알란드 제도"; "Albania" = "알바니아"; "Algeria" = "알제리아"; "American Samoa" = "아메리칸 사모아"; "Andorra" = "안도라"; "Angola" = "앙골라"; "Anguilla" = "안젤라"; "Antarctica" = "남극"; "Antigua and Barbuda" = "앤티가 바부다"; "Argentina" = "아르헨티나"; "Armenia" = "아르메니아"; "Aruba" = "아루바 섬"; "Asia-Pacific Region" = "아시아 태평양 영역"; "Australia" = "호주"; "Austria" = "오스트리아"; "Azerbaijan" = "아제르바이잔"; "Bahamas" = "바하마스"; "Bahrain" = "바레인"; "Bangladesh" = "방글라데시"; "Barbados" = "바베이도스"; "Belarus" = "벨라루스"; "Belgium" = "벨기에"; "Belize" = "벨리즈"; "Benin" = "베냉"; "Bermuda" = "버뮤다"; "Bhutan" = "부탄"; "Bolivia" = "볼리비아"; "Bonaire Saint Eustatius and Saba" = "보네르 성 유스타티우스와 사바"; "Bosnia and Herzegovina" = "보스니아 헤르체코비나 "; "Botswana" = "보츠와나"; "Bouvet Island" = "부베섬"; "Brazil" = "브라질"; "British Indian Ocean Territory" = "영국령 인도양 식민지"; "Brunei Darussalam" = "브루나이 다루살람"; "Bulgaria" = "불가리아"; "Burkina Faso" = "부르키나 파소"; "Burundi" = "부룬디"; "Cambodia" = "캄보디아"; "Cameroon" = "카메룬"; "Canada" = "캐나다"; "Cape Verde" = "포르투갈어"; "Cayman Islands" = "케이맨 제도"; "Central African Republic" = "중앙아프리카 공화국"; "Chad" = "차드"; "Chile" = "칠레"; "China" = "중국"; "Christmas Island" = "크리스마스 섬"; "Cocos Islands" = "코코스 제도"; "Colombia" = "콜롬비아"; "Comoros" = "코모로"; "Congo" = "콩고"; "The Democratic Republic of the Congo" = "콩고민주공화국"; "Cook Islands" = "쿡제도"; "Costa Rica" = "코스타리카"; "Cote D'Ivoire" = "코트디부아르"; "Croatia" = "크로아티아"; "Cuba" = "쿠바"; "Curacao" = "큐라소"; "Cyprus" = "키프로스"; "Czech Republic" = "체코 공화국"; "Denmark" = "덴마크"; "Djibouti" = "지부티"; "Dominica" = "도미니카"; "Dominican Republic" = "도미니카 공화국"; "Ecuador" = "에콰도르"; "Egypt" = "이집트"; "El Salvador" = "엘살바도르"; "Equatorial Guinea" = "적도 기니"; "Eritrea" = "에리트레아"; "Estonia" = "에스토니아"; "Ethiopia" = "에티오피아"; "Europe" = "유럽"; "Falkland Islands" = "포클랜드 제도"; "Faroe Islands" = "페로스 제도"; "Fiji" = "피지"; "Finland" = "핀란드"; "France" = "프랑스"; "French Guiana" = "프랑스령 기아나"; "French Polynesia" = "프랑스령 폴리네시아"; "French Southern Territories" = "프랑스령 남부와 남극지역"; "Gabon" = "가봉"; "Gambia" = "감비아"; "Georgia" = "그루지야"; "Germany" = "독일"; "Ghana" = "가나"; "Gibraltar" = "지브롤터"; "Greece" = "희랍"; "Greenland" = "그린란드"; "Grenada" = "그레나다"; "Guadeloupe" = "과들루프 섬"; "Guam" = "괌"; "Guatemala" = "과테말라"; "Guernsey" = "건지종 젖소"; "Guinea" = "기니"; "Guinea-Bissau" = "기니비사우"; "Guyana" = "가이아나"; "Haiti" = "아이티"; "Heard Island and McDonald Islands" = "허드 맥도널드 제도"; "Vatican City State" = "바티칸 시국"; "Honduras" = "온두라스"; "Hong Kong" = "홍콩"; "Hungary" = "헝가리"; "Iceland" = "Iceland"; "India" = "인도"; "Indonesia" = "인도네시아"; "Iran" = "이란"; "Iraq" = "이라크"; "Ireland" = "아일랜드"; "Isle of Man" = "맨 섬"; "Israel" = "이스라엘"; "Italy" = "이탈리아"; "Jamaica" = "자마이카"; "Japan" = "일본"; "Jersey" = "저시"; "Jordan" = "요단"; "Kazakhstan" = "카자흐스탄"; "Kenya" = "케냐"; "Kiribati" = "키리바시"; "Kuwait" = "쿠웨이트"; "Kyrgyzstan" = "키르기스스탄"; "Lao People's Democratic Republic" = "라오 인민민주공화국"; "Latvia" = "라트비아"; "Lebanon" = "레바논"; "Lesotho" = "레소토"; "Liberia" = "리베리아"; "Libya" = "리비아"; "Liechtenstein" = "리히텐슈타인"; "Lithuania" = "리투아니아"; "Luxembourg" = "룩셈부르크"; "Macau" = "마카오"; "Macedonia" = "마케도니아"; "Madagascar" = "마다스카르"; "Malawi" = "말라위"; "Malaysia" = "말레이시아"; "Maldives" = "말디브"; "Mali" = "말리"; "Malta" = "말타"; "Marshall Islands" = "마샬군도"; "Martinique" = "마르티니크"; "Mauritania" = "모리타니아"; "Mauritius" = "모리셔스"; "Mayotte" = "마요트 섬"; "Mexico" = "맥시코"; "Micronesia" = "마크로네시아"; "Moldova" = "몰도바"; "Monaco" = "모나코"; "Mongolia" = "몽콜"; "Montenegro" = "몬테네그로"; "Montserrat" = "몬트세라트섬"; "Morocco" = "모로코가족"; "Mozambique" = "모잠비크"; "Myanmar" = "미얀마"; "Namibia" = "나미비아"; "Nauru" = "나우루"; "Nepal" = "네팔"; "Netherlands" = "네덜란드"; "New Caledonia" = "뉴칼레도니아"; "New Zealand" = "뉴질랜드"; "Nicaragua" = "나카라과"; "Niger" = "니제르"; "Nigeria" = "나이지리아"; "Niue" = "니우에 섬"; "Norfolk Island" = "노퍽섬"; "North Korea" = "북한"; "Northern Mariana Islands" = "북마리아나제도"; "Norway" = "노르웨이"; "Oman" = "오만"; "Pakistan" = "파키스탄"; "Palau" = "팔라우"; "Palestinian Territory" = "팔레스타인의 지역"; "Panama" = "파나마모자"; "Papua New Guinea" = "파푸아뉴기니"; "Paraguay" = "파라과이"; "Peru" = "페루"; "Philippines" = "필리핀"; "Pitcairn Islands" = "핏케언 제도"; "Poland" = "폴란드"; "Portugal" = "포르투갈"; "Puerto Rico" = "푸에르토리코"; "Qatar" = "카타로"; "Reunion" = "레워니옹"; "Romania" = "루마니아"; "Russian Federation" = "러시아 연방"; "Rwanda" = "르완다"; "Saint Barthelemy" = "생바르텔레미"; "Saint Helena" = "세인츠헬레나 섬"; "Saint Kitts and Nevis" = "세인트키츠네비스"; "Saint Lucia" = "세인트루시아"; "Saint Martin" = "세인트 마틴"; "Saint Pierre and Miquelon" = "생피에르 미글롱"; "Saint Vincent and the Grenadines" = "세인트빈센트 그레나딘"; "Samoa" = "사모아"; "San Marino" = "산마리노"; "Sao Tome and Principe" = "상투메 프린시페 도브라"; "Saudi Arabia" = "사우디 아라비아"; "Senegal" = "세네갈"; "Serbia" = "세르비아"; "Seychelles" = "세이셸"; "Sierra Leone" = "시에라리온"; "Singapore" = "싱가포르"; "Sint Maarten" = "신트마르턴"; "Slovakia" = "슬로바키아"; "Slovenia" = "슬로베니아"; "Solomon Islands" = "솔로몬 제도"; "Somalia" = "소말리아"; "South Africa" = "남아프리카"; "South Georgia and the South Sandwich Islands" = "사우스조지아 사우스샌드위치 제도"; "South Korea" = "한국"; "South Sudan" = "남수단"; "Spain" = "스페인"; "Sri Lanka" = "스리랑카"; "Sudan" = "수단"; "Suriname" = "수리남"; "Svalbard and Jan Mayen" = "스발바르 얀마옌 제도"; "Swaziland" = "스와질란드"; "Sweden" = "스웨덴"; "Switzerland" = "스위스"; "Syrian Arab Republic" = "시리아"; "Taiwan" = "대만"; "Tajikistan" = "타지키스탄"; "Tanzania" = "탄지니아"; "Thailand" = "태국"; "Timor-Leste" = "동티모르"; "Togo" = "토고"; "Tokelau" = "토켈라우"; "Tonga" = "통가"; "Trinidad and Tobago" = "트리니다드토바고"; "Tunisia" = "튀니지"; "Turkey" = "터키"; "Turkmenistan" = "투르크메니스탄"; "Turks and Caicos Islands" = "터크스카이코스 제도"; "Tuvalu" = "투발루"; "Uganda" = "우간다"; "Ukraine" = "우크라이나"; "United Arab Emirates" = "아랍 에미리트 연합국"; "United Kingdom" = "영국"; "United States" = "미국"; "United States Minor Outlying Islands" = "미국령 군소 제도"; "Uruguay" = "우루과이"; "Uzbekistan" = "우즈베키스탄"; "Vanuatu" = "바누어투"; "Venezuela" = "베네수엘라"; "Vietnam" = "베트남"; "British Virgin Islands" = "영국령 버진 제도"; "U.S. Virgin Islands" = "세인트존 섬"; "Wallis and Futuna" = "월리스 푸투나제도"; "Western Sahara" = "서사하라"; "Yemen" = "예멘"; "Zambia" = "잠비아"; "Zimbabwe" = "짐바브웨"; +"enum.language.value.invalid" = "무효"; +"enum.language.value.other" = "Other"; +"enum.language.value.afrikaans" = "아프리칸스어"; +"enum.language.value.albanian" = "알바니아어"; +"enum.language.value.arabic" = "아랍어"; +"enum.language.value.bengali" = "벵갈어"; +"enum.language.value.bosnian" = "보스니아어"; +"enum.language.value.bulgarian" = "불가리아어"; +"enum.language.value.burmese" = "버마어"; +"enum.language.value.catalan" = "카탈루냐어"; +"enum.language.value.cebuano" = "세부어"; +"enum.language.value.chinese" = "중국어"; +"enum.language.value.croatian" = "크로아티아어"; +"enum.language.value.czech" = "체코어"; +"enum.language.value.danish" = "덴마크어"; +"enum.language.value.dutch" = "네덜란드어"; +"enum.language.value.english" = "영어"; +"enum.language.value.esperanto" = "국제어"; +"enum.language.value.estonian" = "에스토니아어"; +"enum.language.value.finnish" = "핀란드어"; +"enum.language.value.french" = "프랑스어"; +"enum.language.value.georgian" = "그루지야어"; +"enum.language.value.german" = "독일어"; +"enum.language.value.greek" = "그리스어"; +"enum.language.value.hebrew" = "히브리어"; +"enum.language.value.hindi" = "힌디어"; +"enum.language.value.hmong" = "묘어"; +"enum.language.value.hungarian" = "헝가리어"; +"enum.language.value.indonesian" = "인도네시아어"; +"enum.language.value.italian" = "이탈리아어"; +"enum.language.value.japanese" = "일본어"; +"enum.language.value.kazakh" = "카자흐어"; +"enum.language.value.khmer" = "크메르원"; +"enum.language.value.korean" = "한국어"; +"enum.language.value.kurdish" = "쿠르드어"; +"enum.language.value.lao" = "라오스어"; +"enum.language.value.latin" = "라틴어"; +"enum.language.value.mongolian" = "몽골어"; +"enum.language.value.ndebele" = "은데벨리어"; +"enum.language.value.nepali" = "네팔어"; +"enum.language.value.norwegian" = "노르웨이어로"; +"enum.language.value.oromo" = "오로모어"; +"enum.language.value.pashto" = "파슈토어"; +"enum.language.value.persian" = "페르시아어"; +"enum.language.value.polish" = "폴란드어"; +"enum.language.value.portuguese" = "포르투갈어"; +"enum.language.value.punjabi" = "펀자브어"; +"enum.language.value.romanian" = "루마니아어"; +"enum.language.value.russian" = "러시아어"; +"enum.language.value.sango" = "쌍고어"; +"enum.language.value.serbian" = "세르비아어"; +"enum.language.value.shona" = "쇼나어"; +"enum.language.value.slovak" = "슬로바키아어"; +"enum.language.value.slovenian" = "슬로베니아어"; +"enum.language.value.somali" = "소말리아어"; +"enum.language.value.spanish" = "스페인어"; +"enum.language.value.swahili" = "스와히리어로"; +"enum.language.value.swedish" = "스웨덴어"; +"enum.language.value.tagalog" = "타갈로어"; +"enum.language.value.thai" = "타이어"; +"enum.language.value.tigrinya" = "티글리니아어"; +"enum.language.value.turkish" = "터키어"; +"enum.language.value.ukrainian" = "우크라이나어"; +"enum.language.value.urdu" = "우르두어"; +"enum.language.value.vietnamese" = "베트남어"; +"enum.language.value.zulu" = "줄루어"; + +// MARK: BrowsingCountry +"enum.browsing.country.name.autoDetect" = "자동으로 설정"; +"enum.browsing.country.name.afghanistan" = "아프가니스탄"; +"enum.browsing.country.name.alandIslands" = "알란드 제도"; +"enum.browsing.country.name.albania" = "알바니아"; +"enum.browsing.country.name.algeria" = "알제리아"; +"enum.browsing.country.name.americanSamoa" = "아메리칸 사모아"; +"enum.browsing.country.name.andorra" = "안도라"; +"enum.browsing.country.name.angola" = "앙골라"; +"enum.browsing.country.name.anguilla" = "안젤라"; +"enum.browsing.country.name.antarctica" = "남극"; +"enum.browsing.country.name.antiguaAndBarbuda" = "앤티가 바부다"; +"enum.browsing.country.name.argentina" = "아르헨티나"; +"enum.browsing.country.name.armenia" = "아르메니아"; +"enum.browsing.country.name.aruba" = "아루바 섬"; +"enum.browsing.country.name.asiaPacificRegion" = "아시아 태평양 영역"; +"enum.browsing.country.name.australia" = "호주"; +"enum.browsing.country.name.austria" = "오스트리아"; +"enum.browsing.country.name.azerbaijan" = "아제르바이잔"; +"enum.browsing.country.name.bahamas" = "바하마스"; +"enum.browsing.country.name.bahrain" = "바레인"; +"enum.browsing.country.name.bangladesh" = "방글라데시"; +"enum.browsing.country.name.barbados" = "바베이도스"; +"enum.browsing.country.name.belarus" = "벨라루스"; +"enum.browsing.country.name.belgium" = "벨기에"; +"enum.browsing.country.name.belize" = "벨리즈"; +"enum.browsing.country.name.benin" = "베냉"; +"enum.browsing.country.name.bermuda" = "버뮤다"; +"enum.browsing.country.name.bhutan" = "부탄"; +"enum.browsing.country.name.bolivia" = "볼리비아"; +"enum.browsing.country.name.bonaireSaintEustatiusAndSaba" = "보네르 성 유스타티우스와 사바"; +"enum.browsing.country.name.bosniaAndHerzegovina" = "보스니아 헤르체코비나 "; +"enum.browsing.country.name.botswana" = "보츠와나"; +"enum.browsing.country.name.bouvetIsland" = "부베섬"; +"enum.browsing.country.name.brazil" = "브라질"; +"enum.browsing.country.name.britishIndianOceanTerritory" = "영국령 인도양 식민지"; +"enum.browsing.country.name.bruneiDarussalam" = "브루나이 다루살람"; +"enum.browsing.country.name.bulgaria" = "불가리아"; +"enum.browsing.country.name.burkinaFaso" = "부르키나 파소"; +"enum.browsing.country.name.burundi" = "부룬디"; +"enum.browsing.country.name.cambodia" = "캄보디아"; +"enum.browsing.country.name.cameroon" = "카메룬"; +"enum.browsing.country.name.canada" = "캐나다"; +"enum.browsing.country.name.capeVerde" = "포르투갈어"; +"enum.browsing.country.name.caymanIslands" = "케이맨 제도"; +"enum.browsing.country.name.centralAfricanRepublic" = "중앙아프리카 공화국"; +"enum.browsing.country.name.chad" = "차드"; +"enum.browsing.country.name.chile" = "칠레"; +"enum.browsing.country.name.china" = "중국"; +"enum.browsing.country.name.christmasIsland" = "크리스마스 섬"; +"enum.browsing.country.name.cocosIslands" = "코코스 제도"; +"enum.browsing.country.name.colombia" = "콜롬비아"; +"enum.browsing.country.name.comoros" = "코모로"; +"enum.browsing.country.name.congo" = "콩고"; +"enum.browsing.country.name.theDemocraticRepublicOfTheCongo" = "콩고민주공화국"; +"enum.browsing.country.name.cookIslands" = "쿡제도"; +"enum.browsing.country.name.costaRica" = "코스타리카"; +"enum.browsing.country.name.coteDIvoire" = "코트디부아르"; +"enum.browsing.country.name.croatia" = "크로아티아"; +"enum.browsing.country.name.cuba" = "쿠바"; +"enum.browsing.country.name.curacao" = "큐라소"; +"enum.browsing.country.name.cyprus" = "키프로스"; +"enum.browsing.country.name.czechRepublic" = "체코 공화국"; +"enum.browsing.country.name.denmark" = "덴마크"; +"enum.browsing.country.name.djibouti" = "지부티"; +"enum.browsing.country.name.dominica" = "도미니카"; +"enum.browsing.country.name.dominicanRepublic" = "도미니카 공화국"; +"enum.browsing.country.name.ecuador" = "에콰도르"; +"enum.browsing.country.name.egypt" = "이집트"; +"enum.browsing.country.name.elSalvador" = "엘살바도르"; +"enum.browsing.country.name.equatorialGuinea" = "적도 기니"; +"enum.browsing.country.name.eritrea" = "에리트레아"; +"enum.browsing.country.name.estonia" = "에스토니아"; +"enum.browsing.country.name.ethiopia" = "에티오피아"; +"enum.browsing.country.name.europe" = "유럽"; +"enum.browsing.country.name.falklandIslands" = "포클랜드 제도"; +"enum.browsing.country.name.faroeIslands" = "페로스 제도"; +"enum.browsing.country.name.fiji" = "피지"; +"enum.browsing.country.name.finland" = "핀란드"; +"enum.browsing.country.name.france" = "프랑스"; +"enum.browsing.country.name.frenchGuiana" = "프랑스령 기아나"; +"enum.browsing.country.name.frenchPolynesia" = "프랑스령 폴리네시아"; +"enum.browsing.country.name.frenchSouthernTerritories" = "프랑스령 남부와 남극지역"; +"enum.browsing.country.name.gabon" = "가봉"; +"enum.browsing.country.name.gambia" = "감비아"; +"enum.browsing.country.name.georgia" = "그루지야"; +"enum.browsing.country.name.germany" = "독일"; +"enum.browsing.country.name.ghana" = "가나"; +"enum.browsing.country.name.gibraltar" = "지브롤터"; +"enum.browsing.country.name.greece" = "희랍"; +"enum.browsing.country.name.greenland" = "그린란드"; +"enum.browsing.country.name.grenada" = "그레나다"; +"enum.browsing.country.name.guadeloupe" = "과들루프 섬"; +"enum.browsing.country.name.guam" = "괌"; +"enum.browsing.country.name.guatemala" = "과테말라"; +"enum.browsing.country.name.guernsey" = "건지종 젖소"; +"enum.browsing.country.name.guinea" = "기니"; +"enum.browsing.country.name.guineaBissau" = "기니비사우"; +"enum.browsing.country.name.guyana" = "가이아나"; +"enum.browsing.country.name.haiti" = "아이티"; +"enum.browsing.country.name.heardIslandAndMcDonaldIslands" = "허드 맥도널드 제도"; +"enum.browsing.country.name.vaticanCityState" = "바티칸 시국"; +"enum.browsing.country.name.honduras" = "온두라스"; +"enum.browsing.country.name.hongKong" = "홍콩"; +"enum.browsing.country.name.hungary" = "헝가리"; +"enum.browsing.country.name.iceland" = "Iceland"; +"enum.browsing.country.name.india" = "인도"; +"enum.browsing.country.name.indonesia" = "인도네시아"; +"enum.browsing.country.name.iran" = "이란"; +"enum.browsing.country.name.iraq" = "이라크"; +"enum.browsing.country.name.ireland" = "아일랜드"; +"enum.browsing.country.name.isleOfMan" = "맨 섬"; +"enum.browsing.country.name.israel" = "이스라엘"; +"enum.browsing.country.name.italy" = "이탈리아"; +"enum.browsing.country.name.jamaica" = "자마이카"; +"enum.browsing.country.name.japan" = "일본"; +"enum.browsing.country.name.jersey" = "저시"; +"enum.browsing.country.name.jordan" = "요단"; +"enum.browsing.country.name.kazakhstan" = "카자흐스탄"; +"enum.browsing.country.name.kenya" = "케냐"; +"enum.browsing.country.name.kiribati" = "키리바시"; +"enum.browsing.country.name.kuwait" = "쿠웨이트"; +"enum.browsing.country.name.kyrgyzstan" = "키르기스스탄"; +"enum.browsing.country.name.laoPeoplesDemocraticRepublic" = "라오 인민민주공화국"; +"enum.browsing.country.name.latvia" = "라트비아"; +"enum.browsing.country.name.lebanon" = "레바논"; +"enum.browsing.country.name.lesotho" = "레소토"; +"enum.browsing.country.name.liberia" = "리베리아"; +"enum.browsing.country.name.libya" = "리비아"; +"enum.browsing.country.name.liechtenstein" = "리히텐슈타인"; +"enum.browsing.country.name.lithuania" = "리투아니아"; +"enum.browsing.country.name.luxembourg" = "룩셈부르크"; +"enum.browsing.country.name.macau" = "마카오"; +"enum.browsing.country.name.macedonia" = "마케도니아"; +"enum.browsing.country.name.madagascar" = "마다스카르"; +"enum.browsing.country.name.malawi" = "말라위"; +"enum.browsing.country.name.malaysia" = "말레이시아"; +"enum.browsing.country.name.maldives" = "말디브"; +"enum.browsing.country.name.mali" = "말리"; +"enum.browsing.country.name.malta" = "말타"; +"enum.browsing.country.name.marshallIslands" = "마샬군도"; +"enum.browsing.country.name.martinique" = "마르티니크"; +"enum.browsing.country.name.mauritania" = "모리타니아"; +"enum.browsing.country.name.mauritius" = "모리셔스"; +"enum.browsing.country.name.mayotte" = "마요트 섬"; +"enum.browsing.country.name.mexico" = "맥시코"; +"enum.browsing.country.name.micronesia" = "마크로네시아"; +"enum.browsing.country.name.moldova" = "몰도바"; +"enum.browsing.country.name.monaco" = "모나코"; +"enum.browsing.country.name.mongolia" = "몽콜"; +"enum.browsing.country.name.montenegro" = "몬테네그로"; +"enum.browsing.country.name.montserrat" = "몬트세라트섬"; +"enum.browsing.country.name.morocco" = "모로코가족"; +"enum.browsing.country.name.mozambique" = "모잠비크"; +"enum.browsing.country.name.myanmar" = "미얀마"; +"enum.browsing.country.name.namibia" = "나미비아"; +"enum.browsing.country.name.nauru" = "나우루"; +"enum.browsing.country.name.nepal" = "네팔"; +"enum.browsing.country.name.netherlands" = "네덜란드"; +"enum.browsing.country.name.newCaledonia" = "뉴칼레도니아"; +"enum.browsing.country.name.newZealand" = "뉴질랜드"; +"enum.browsing.country.name.nicaragua" = "나카라과"; +"enum.browsing.country.name.niger" = "니제르"; +"enum.browsing.country.name.nigeria" = "나이지리아"; +"enum.browsing.country.name.niue" = "니우에 섬"; +"enum.browsing.country.name.norfolkIsland" = "노퍽섬"; +"enum.browsing.country.name.northKorea" = "북한"; +"enum.browsing.country.name.northernMarianaIslands" = "북마리아나제도"; +"enum.browsing.country.name.norway" = "노르웨이"; +"enum.browsing.country.name.oman" = "오만"; +"enum.browsing.country.name.pakistan" = "파키스탄"; +"enum.browsing.country.name.palau" = "팔라우"; +"enum.browsing.country.name.palestinianTerritory" = "팔레스타인의 지역"; +"enum.browsing.country.name.panama" = "파나마모자"; +"enum.browsing.country.name.papuaNewGuinea" = "파푸아뉴기니"; +"enum.browsing.country.name.paraguay" = "파라과이"; +"enum.browsing.country.name.peru" = "페루"; +"enum.browsing.country.name.philippines" = "필리핀"; +"enum.browsing.country.name.pitcairnIslands" = "핏케언 제도"; +"enum.browsing.country.name.poland" = "폴란드"; +"enum.browsing.country.name.portugal" = "포르투갈"; +"enum.browsing.country.name.puertoRico" = "푸에르토리코"; +"enum.browsing.country.name.qatar" = "카타로"; +"enum.browsing.country.name.reunion" = "레워니옹"; +"enum.browsing.country.name.romania" = "루마니아"; +"enum.browsing.country.name.russianFederation" = "러시아 연방"; +"enum.browsing.country.name.rwanda" = "르완다"; +"enum.browsing.country.name.saintBarthelemy" = "생바르텔레미"; +"enum.browsing.country.name.saintHelena" = "세인츠헬레나 섬"; +"enum.browsing.country.name.saintKittsAndNevis" = "세인트키츠네비스"; +"enum.browsing.country.name.saintLucia" = "세인트루시아"; +"enum.browsing.country.name.saintMartin" = "세인트 마틴"; +"enum.browsing.country.name.saintPierreAndMiquelon" = "생피에르 미글롱"; +"enum.browsing.country.name.saintVincentAndTheGrenadines" = "세인트빈센트 그레나딘"; +"enum.browsing.country.name.samoa" = "사모아"; +"enum.browsing.country.name.sanMarino" = "산마리노"; +"enum.browsing.country.name.saoTomeAndPrincipe" = "상투메 프린시페 도브라"; +"enum.browsing.country.name.saudiArabia" = "사우디 아라비아"; +"enum.browsing.country.name.senegal" = "세네갈"; +"enum.browsing.country.name.serbia" = "세르비아"; +"enum.browsing.country.name.seychelles" = "세이셸"; +"enum.browsing.country.name.sierraLeone" = "시에라리온"; +"enum.browsing.country.name.singapore" = "싱가포르"; +"enum.browsing.country.name.sintMaarten" = "신트마르턴"; +"enum.browsing.country.name.slovakia" = "슬로바키아"; +"enum.browsing.country.name.slovenia" = "슬로베니아"; +"enum.browsing.country.name.solomonIslands" = "솔로몬 제도"; +"enum.browsing.country.name.somalia" = "소말리아"; +"enum.browsing.country.name.southAfrica" = "남아프리카"; +"enum.browsing.country.name.southGeorgiaAndTheSouthSandwichIslands" = "사우스조지아 사우스샌드위치 제도"; +"enum.browsing.country.name.southKorea" = "한국"; +"enum.browsing.country.name.southSudan" = "남수단"; +"enum.browsing.country.name.spain" = "스페인"; +"enum.browsing.country.name.sriLanka" = "스리랑카"; +"enum.browsing.country.name.sudan" = "수단"; +"enum.browsing.country.name.suriname" = "수리남"; +"enum.browsing.country.name.svalbardAndJanMayen" = "스발바르 얀마옌 제도"; +"enum.browsing.country.name.swaziland" = "스와질란드"; +"enum.browsing.country.name.sweden" = "스웨덴"; +"enum.browsing.country.name.switzerland" = "스위스"; +"enum.browsing.country.name.syrianArabRepublic" = "시리아"; +"enum.browsing.country.name.taiwan" = "대만"; +"enum.browsing.country.name.tajikistan" = "타지키스탄"; +"enum.browsing.country.name.tanzania" = "탄지니아"; +"enum.browsing.country.name.thailand" = "태국"; +"enum.browsing.country.name.timorLeste" = "동티모르"; +"enum.browsing.country.name.togo" = "토고"; +"enum.browsing.country.name.tokelau" = "토켈라우"; +"enum.browsing.country.name.tonga" = "통가"; +"enum.browsing.country.name.trinidadAndTobago" = "트리니다드토바고"; +"enum.browsing.country.name.tunisia" = "튀니지"; +"enum.browsing.country.name.turkey" = "터키"; +"enum.browsing.country.name.turkmenistan" = "투르크메니스탄"; +"enum.browsing.country.name.turksAndCaicosIslands" = "터크스카이코스 제도"; +"enum.browsing.country.name.tuvalu" = "투발루"; +"enum.browsing.country.name.uganda" = "우간다"; +"enum.browsing.country.name.ukraine" = "우크라이나"; +"enum.browsing.country.name.unitedArabEmirates" = "아랍 에미리트 연합국"; +"enum.browsing.country.name.unitedKingdom" = "영국"; +"enum.browsing.country.name.unitedStates" = "미국"; +"enum.browsing.country.name.unitedStatesMinorOutlyingIslands" = "미국령 군소 제도"; +"enum.browsing.country.name.uruguay" = "우루과이"; +"enum.browsing.country.name.uzbekistan" = "우즈베키스탄"; +"enum.browsing.country.name.vanuatu" = "바누어투"; +"enum.browsing.country.name.venezuela" = "베네수엘라"; +"enum.browsing.country.name.vietnam" = "베트남"; +"enum.browsing.country.name.virginIslandsBritish" = "영국령 버진 제도"; +"enum.browsing.country.name.virginIslandsUS" = "세인트존 섬"; +"enum.browsing.country.name.wallisAndFutuna" = "월리스 푸투나제도"; +"enum.browsing.country.name.westernSahara" = "서사하라"; +"enum.browsing.country.name.yemen" = "예멘"; +"enum.browsing.country.name.zambia" = "잠비아"; +"enum.browsing.country.name.zimbabwe" = "짐바브웨"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 51b80147..bc11cff9 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -1,474 +1,878 @@ -/* +/* Localizable.strings EhPanda Created by 荒木辰造 on R 2/12/25. - + */ +// MARK: BanInterval +"enum.ban.interval.description.and" = ""; + +// MARK: ToplistsType +"enum.toplists.type.value.yesterday" = "昨日"; +"enum.toplists.type.value.pastMonth" = "上月"; +"enum.toplists.type.value.pastYear" = "去年"; +"enum.toplists.type.value.allTime" = "全部"; + // MARK: Response -"You must have a H@H client assigned to your account to use this feature." = "你需要一个关联到账户的 Hath 客户端才能使用这个功能"; -"Your H@H client appears to be offline. Turn it on, then try again." = "你的 Hath 客户端似乎处于离线状态,请启动它后再试"; -"The requested gallery cannot be downloaded with the selected resolution." = "该画廊不能以选中的分辨率下载"; +"hath.download.response.hathClientNotFound" = "你需要一个关联到账户的 H@H 客户端才能使用这个功能"; +"hath.download.response.hathClientNotOnline" = "你的 H@H 客户端似乎处于离线状态,请启动它后再试"; +"hath.download.response.invalidResolution" = "该画廊不能以选中的分辨率下载"; // MARK: HUD -"Success" = "成功"; -"Error" = "错误"; -"Communicating..." = "与服务器通信中..."; -"Copied to clipboard" = "已复制到剪切板"; +"hud.title.error" = "错误"; +"hud.title.success" = "成功"; +"hud.title.loading" = "加载中..."; +"hud.title.communicating" = "通信中..."; +"hud.caption.copiedToClipboard" = "已复制到剪切板"; +"hud.caption.savedToPhotoLibrary" = "已保存到图库"; + +// MARK: AutoLock +"local.authorization.reason" = "因超过设置的自动锁定期限,App 已被锁定"; + +// MARK: Common value +"common.value.stars" = "%@ 星"; +"common.value.pages" = "%@ 页"; +"common.value.times" = "%@ 次"; +"common.value.day" = "%@ 天"; +"common.value.days" = "%@ 天"; +"common.value.hour" = "%@ 小时"; +"common.value.hours" = "%@ 小时"; +"common.value.minute" = "%@ 分"; +"common.value.minutes" = "%@ 分"; +"common.value.second" = "%@ 秒"; +"common.value.seconds" = "%@ 秒"; +"common.value.records" = "%@ 条记录"; + +// MARK: TabItem +"tab.item.title.home" = "主页"; +"tab.item.title.favorites" = "收藏"; +"tab.item.title.search" = "搜索"; +"tab.item.title.setting" = "设置"; + +// MARK: ToolbarItem +"toolbar.item.button.filters" = "筛选"; +"toolbar.item.button.jumpPage" = "页码跳转"; +"toolbar.item.button.quickSearch" = "快速搜索"; + +// MARK: JumpPage +"jump.page.view.title.jumpPage" = "页码跳转"; +"jump.page.view.button.confirm" = "确认"; -// MARK: LockView -"The App has been locked due to the auto-lock expiration." = "因超过设定的自动锁定期限,App 已被锁定"; +// MARK: AlertView +"loading.view.title.loading" = "加载中..."; +"loading.view.title.preparingDatabase" = "正在准备数据库..."; +"not.login.view.title.needLogin" = "你需要登录才能使用该功能"; +"not.login.view.button.login" = "登录"; +"error.view.button.retry" = "重试"; +"error.view.button.dropDatabase" = "丢弃数据库"; +"error.view.title.tryLater" = "请稍后再试"; +"error.view.title.network" = "发生了网络故障"; +"error.view.title.parsing" = "发生了解析错误"; +"error.view.title.unknown" = "发生了未知错误"; +"error.view.title.notFound" = "这里似乎什么也没有"; +"error.view.title.databaseCorrupted" = "数据库已损毁。\n请到 GitHub 提起 Issue 反馈。"; +"error.view.title.ipBanned" = "当前的 IP 地址发生了过量的页面加载,因有使用爬虫程序的嫌疑已被暂时封禁。封禁将在 %@后解除。"; +"error.view.title.copyrightClaim" = "抱歉,该画廊因 %@ 的版权主张已无法访问。"; +"error.view.title.galleryUnavailable" = "该画廊已被移除或不可用。"; + +// MARK: ConfirmationDialog +"confirmation.dialog.title.dropDatabase" = "你将失去这个 App 中所有的数据。\n确定要丢弃数据库吗?"; +"confirmation.dialog.title.removeCustomTranslations" = "确定要移除自定义翻译吗?"; +"confirmation.dialog.title.logout" = "确定要退出登录吗?"; +"confirmation.dialog.title.delete" = "确定要删除吗?"; +"confirmation.dialog.title.clear" = "确定要清空吗?"; +"confirmation.dialog.title.reset" = "确定要重置吗?"; +"confirmation.dialog.button.dropDatabase" = "丢弃数据库"; +"confirmation.dialog.button.remove" = "移除"; +"confirmation.dialog.button.logout" = "退出登录"; +"confirmation.dialog.button.delete" = "删除"; +"confirmation.dialog.button.clear" = "清空"; +"confirmation.dialog.button.reset" = "重置"; + +// MARK: SubSection +"sub.section.button.showAll" = "显示全部"; -// MARK: Common -"null" = "无内容"; -"expired" = "已过期"; -"mystery" = "被拒绝"; +// MARK: NewDawnView +"new.dawn.view.title.first" = "又是全新的一天!"; +"new.dawn.view.title.second" = "回顾至今的历程,发觉自己更睿智了一些。"; +// Greeting +"struct.greeting.mark.start" = "你获得了 "; +"struct.greeting.mark.separator" = "、"; +"struct.greeting.mark.and" = " 和 "; +"struct.greeting.mark.end" = "!"; -// MARK: User -"favoriteNameByDev" = "收藏夹"; -"all_appendedByDev" = "全部"; +// MARK: HomeView +"home.view.title.home" = "主页"; +"home.view.section.title.frontpage" = "扉页"; +"home.view.section.title.toplists" = "排行"; +"home.view.section.title.other" = "其它"; +// HomeMiscGridType +"home.misc.grid.type.title.popular" = "热门"; +"home.misc.grid.type.title.watched" = "标签"; +"home.misc.grid.type.title.history" = "历史"; + +// MARK: FrontpageView +"frontpage.view.title.frontpage" = "扉页"; + +// MARK: ToplistsView +"toplists.view.title.toplists" = "排行"; + +// MARK: PopularView +"popular.view.title.popular" = "热门"; + +// MARK: WatchedView +"watched.view.title.watched" = "标签"; + +// MARK: HistoryView +"history.view.title.history" = "历史"; + +// MARK: FavoritesView +"favorites.view.title.favorites" = "收藏"; +// FavoriteCategory +"favorite.category.default" = "收藏夹 %@"; +"favorite.category.all" = "全部"; + +// MARK: SearchView +"search.view.title.search" = "搜索"; +"search.view.section.title.recentlySearched" = "最近搜索"; +"search.view.section.title.recentlySeen" = "最近看过"; +"search.view.section.title.quickSearch" = "快速搜索"; +// Searchable prompt +"searchable.prompt.filter" = "筛选"; -// MARK: AlertView -"Loading..." = "加载中..."; -"Login" = "登录"; -"There seems to be nothing here." = "这里似乎什么也没有"; -"Retry" = "重试"; -"A network error occurred." = "发生了网络故障"; -"A parsing error occurred." = "发生了解析错误"; -"An unknown error occurred." = "发生了未知错误"; -"Please try again later." = "请稍后再试"; -"This gallery has been removed or is unavailable." = "该画廊已被移除或不可用。"; -"This gallery is unavailable due to a copyright claim by PLACEHOLDER. Sorry about that." = "抱歉,该画廊因 PLACEHOLDER 的版权主张已无法访问。"; -"Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software." = "当前的 IP 地址发生了过量的页面加载,因有使用爬虫程序的嫌疑已被暂时封禁。"; -"The ban expires in PLACEHOLDER." = "封禁将在 PLACEHOLDER 后解除。"; -"BAN_INTERVAL_AND" = " "; -"BAN_INTERVAL_DAYS" = " 天"; -"BAN_INTERVAL_HOURS" = " 小时"; -"BAN_INTERVAL_MINUTES" = " 分"; -"BAN_INTERVAL_SECONDS" = " 秒"; -"Jump page" = "页码跳转"; -"Confirm" = "确认"; +// MARK: QuickSearchView +"quick.search.view.title.quickSearch" = "快速搜索"; +"quick.search.view.title.editWord" = "编辑关键词"; +"quick.search.view.title.newWord" = "添加关键词"; +"quick.search.view.title.content" = "内容"; +"quick.search.view.title.name" = "名称"; +"quick.search.view.placeholder.optional" = "可选"; +"quick.search.view.toolbar.item.button.confirm" = "确认"; -// MARK: HomeView -"Clear history" = "清空历史"; +// MARK: SettingView +"setting.view.title.setting" = "设置"; +// SettingStateRoute +"enum.setting.state.route.value.account" = "账户"; +"enum.setting.state.route.value.general" = "一般"; +"enum.setting.state.route.value.appearance" = "外观"; +"enum.setting.state.route.value.reading" = "阅读"; +"enum.setting.state.route.value.laboratory" = "实验室"; +"enum.setting.state.route.value.ehPanda" = "关于 EhPanda"; + +// MARK: AccountSettingView +"account.setting.view.title.account" = "账户"; +"account.setting.view.title.showsNewDawnGreeting" = "显示黎明问候"; +"account.setting.view.button.login" = "登录"; +"account.setting.view.button.logout" = "退出登录"; +"account.setting.view.button.accountConfiguration" = "账户设置"; +"account.setting.view.button.tagsManagement" = "管理标签订阅"; +"account.setting.view.button.copyCookies" = "复制 Cookies"; +// CookieValue +"struct.cookie.value.localized.string.expired" = "已过期"; +"struct.cookie.value.localized.string.mystery" = "被拒绝"; +"struct.cookie.value.localized.string.none" = "无内容"; + +// MARK: LoginView +"login.view.title.login" = "登录"; +"login.view.title.username" = "用户名"; +"login.view.title.password" = "密码"; + +// MARK: GeneralSettingView +"general.setting.view.title.general" = "一般"; +"general.setting.view.title.language" = "语言"; +"general.setting.view.title.autoLock" = "自动锁定"; +"general.setting.view.title.translatesTags" = "翻译标签"; +"general.setting.view.title.redirectsLinksToTheSelectedHost" = "重定向链接到选定的站点"; +"general.setting.view.title.detectsLinksFromClipboard" = "从剪切板检测链接"; +"general.setting.view.title.backgroundBlurRadius" = "后台模糊效果"; +"general.setting.view.button.logs" = "日志"; +"general.setting.view.button.importCustomTranslations" = "导入自定义翻译"; +"general.setting.view.button.removeCustomTranslations" = "移除自定义翻译"; +"general.setting.view.button.clearImageCaches" = "清空图片缓存"; +"general.setting.view.value.defaultLanguageDescription" = "无效"; +"general.setting.view.section.title.tagsTranslation" = "标签翻译"; +"general.setting.view.section.title.navigation" = "导航"; +"general.setting.view.section.title.security" = "安全"; +"general.setting.view.section.title.caches" = "缓存"; +// AutoLockPolicy +"enum.auto.lock.policy.value.never" = "不锁定"; +"enum.auto.lock.policy.value.instantly" = "立即"; + +// MARK: LogsView +"logs.view.title.logs" = "日志"; +"logs.view.title.latest" = "最新"; + +// MARK: AppearanceSettingView +"appearance.setting.view.title.appearance" = "外观"; +"appearance.setting.view.title.theme" = "主题"; +"appearance.setting.view.title.tintColor" = "主题色"; +"appearance.setting.view.title.displayMode" = "显示样式"; +"appearance.setting.view.title.showsTagsInList" = "在列表中显示标签"; +"appearance.setting.view.title.maximumNumberOfTags" = "标签数量上限"; +"appearance.setting.view.button.appIcon" = "应用图标"; +"appearance.setting.view.menu.title.infite" = "无限"; +"appearance.setting.view.section.title.list" = "列表"; +// PerferredColorScheme +"enum.perferred.color.scheme.value.automatic" = "自动"; +"enum.perferred.color.scheme.value.light" = "浅色"; +"enum.perferred.color.scheme.value.dark" = "深色"; +// AppIconType +"enum.app.icon.type.value.default" = "默认"; +"enum.app.icon.type.value.ukiyoe" = "浮世绘"; +// ListDisplayMode +"enum.display.mode.value.detail" = "详情"; +"enum.display.mode.value.thumbnail" = "缩略图"; + +// MARK: AppIconView +"app.icon.view.title.appIcon" = "应用图标"; + +// MARK: ReadingSettingView +"reading.setting.view.title.reading" = "阅读"; +"reading.setting.view.title.direction" = "方向"; +"reading.setting.view.title.preloadLimit" = "预加载数量上限"; +"reading.setting.view.title.enablesLandscape" = "启用横屏"; +"reading.setting.view.title.separatorHeight" = "分隔线高度"; +"reading.setting.view.title.maximumScaleFactor" = "最大缩放系数"; +"reading.setting.view.title.doubleTapScaleFactor" = "双击缩放系数"; +"reading.setting.view.section.title.appearance" = "外观"; +// ReadingDirection +"enum.reading.direction.value.vertical" = "垂直"; +"enum.reading.direction.value.rightToLeft" = "右至左"; +"enum.reading.direction.value.leftToRight" = "左至右"; + +// MARK: LaboratorySettingView +"laboratory.setting.view.title.laboratory" = "实验室"; +"laboratory.setting.view.title.bypassesSNIFiltering" = "域前置绕过 SNI 阻断"; + +// MARK: EhPandaView +"ehpanda.view.title.ehPanda" = "EhPanda"; +"ehpanda.view.button.website" = "网站"; +"ehpanda.view.button.altStoreSource" = "AltStore 源"; +"ehpanda.view.description.version" = "版本"; +"ehpanda.view.section.title.specialThanks" = "特别致谢"; +"ehpanda.view.section.title.codeLevelContributors" = "代码级贡献者"; +"ehpanda.view.section.title.translationContributors" = "翻译贡献者"; +"ehpanda.view.section.title.acknowledgements" = "致谢"; // MARK: DetailView -"Archive" = "归档"; -"Torrents" = "种子"; -"Share" = "分享"; -"Read" = "阅读"; -"DESC_SCROLL_ITEM_FAVORITED" = "收藏"; -"Times" = "次"; -"Language" = "语言"; -"%lld Ratings" = "%lld 个评分"; -"Page Count" = "页数"; -"Pages" = "页"; -"File Size" = "文件大小"; -"Give a Rating" = "发布评分"; -"Similar Gallery" = "相似画廊"; -"Preview" = "预览"; -"Comment" = "评论"; -"Show All" = "显示全部"; - -// MARK: ArchiveView -"N/A" = "无效"; -"Free" = "免费"; -"ARCHIVE_RESOLUTION_ORIGINAL" = "原始分辨率"; -"Download To Hath Client" = "下载到 Hath 客户端"; +"detail.view.button.read" = "阅读"; +"detail.view.button.postComment" = "发布评论"; +"detail.view.toolbar.item.button.archives" = "归档"; +"detail.view.toolbar.item.button.torrents" = "种子"; +"detail.view.toolbar.item.button.share" = "分享"; +"detail.view.scroll.section.title.favorited" = "收藏"; +"detail.view.scroll.section.title.language" = "语言"; +"detail.view.scroll.section.title.ratings" = "%@ 个评分"; +"detail.view.scroll.section.title.pageCount" = "页数"; +"detail.view.scroll.section.title.fileSize" = "文件大小"; +"detail.view.scroll.section.description.favorited" = "次"; +"detail.view.scroll.section.description.pageCount" = "页"; +"detail.view.action.section.button.giveARating" = "给予评分"; +"detail.view.action.section.button.similarGallery" = "相似画廊"; +"detail.view.section.title.previews" = "预览"; +"detail.view.section.title.comments" = "评论"; + +// MARK: ArchivesView +"archives.view.title.archives" = "归档"; +"archives.view.button.downloadToHathClient" = "下载到 H@H 客户端"; +// HathArchive +"struct.hath.archive.price.value.free" = "免费"; +"struct.hath.archive.price.value.notAvailable" = "无效"; +"struct.hath.archive.resolution.value.original" = "原始分辨率"; + +// MARK: TorrentsView +"torrents.view.title.torrents" = "种子"; // MARK: GalleryInfosView -"Gallery infos" = "画廊信息"; -"Title" = "标题"; -"Japanese title" = "日文标题"; -"Gallery URL" = "画廊地址"; -"Cover URL" = "封面地址"; -"Archive URL" = "归档地址"; -"Torrent URL" = "种子地址"; -"Parent URL" = "上游画廊地址"; -"Category" = "分类"; -"Uploader" = "上传者"; -"Posted date" = "发布日期"; -"Visible" = "可见"; -"Page count" = "页数"; -"File size" = "文件大小"; -"Favorited times" = "收藏次数"; -"Favorited" = "已收藏"; -"Rating count" = "评分次数"; -"Average rating" = "平均评分"; -"User rating" = "用户评分"; -"Torrent count" = "种子个数"; -"Yes" = "是"; -"No" = "否"; -"Expunged" = "已删除"; - -// MARK: CommentView -"Post Comment" = "发布评论"; -"Edit Comment" = "编辑评论"; -"Cancel" = "取消"; -"Post" = "发布"; +"gallery.infos.view.title.galleryInfos" = "画廊信息"; +"gallery.infos.view.title.ID" = "ID"; +"gallery.infos.view.title.token" = "Token"; +"gallery.infos.view.title.title" = "标题"; +"gallery.infos.view.title.japaneseTitle" = "日文标题"; +"gallery.infos.view.title.galleryURL" = "画廊链接"; +"gallery.infos.view.title.coverURL" = "封面链接"; +"gallery.infos.view.title.archiveURL" = "归档链接"; +"gallery.infos.view.title.torrentURL" = "种子链接"; +"gallery.infos.view.title.parentURL" = "上游画廊链接"; +"gallery.infos.view.title.category" = "分类"; +"gallery.infos.view.title.uploader" = "上传者"; +"gallery.infos.view.title.postedDate" = "发布日期"; +"gallery.infos.view.title.visibility" = "可见"; +"gallery.infos.view.title.language" = "语言"; +"gallery.infos.view.title.pageCount" = "页数"; +"gallery.infos.view.title.fileSize" = "文件大小"; +"gallery.infos.view.title.favoritedTimes" = "收藏次数"; +"gallery.infos.view.title.favorited" = "已收藏"; +"gallery.infos.view.title.ratingCount" = "评分次数"; +"gallery.infos.view.title.averageRating" = "平均评分"; +"gallery.infos.view.title.myRating" = "我的评分"; +"gallery.infos.view.title.torrentCount" = "种子个数"; +"gallery.infos.view.value.none" = "无"; +"gallery.infos.view.value.yes" = "是"; +"gallery.infos.view.value.no" = "否"; +// GalleryVisibility +"gallery.visibility.value.yes" = "是"; +"gallery.visibility.value.no" = "否 (%@)"; +"gallery.visibility.value.no.reason.expunged" = "已删除"; + +// MARK: CommentsView +"comments.view.title.comments" = "评论"; + +// MARK: PostCommentView +"post.comment.view.title.postComment" = "发布评论"; +"post.comment.view.title.editComment" = "编辑评论"; +"post.comment.view.button.cancel" = "取消"; +"post.comment.view.button.post" = "发布"; + +// MARK: PreviewsView +"previews.view.title.previews" = "预览"; // MARK: ReadingView -"AutoPlay" = "自动播放"; -"Reload" = "重新加载"; -"Copy" = "复制"; -"Save" = "保存"; -"Save original" = "保存原图"; -"Saved to photo library" = "已保存到图库"; - -// MARK: SettingView -"Setting" = "设置"; -"Account" = "账户"; -"Gallery" = "画廊"; -"Login" = "登录"; -"Username" = "用户名"; -"Password" = "密码"; -"Logout" = "退出登录"; -"Are you sure to logout?" = "确定要退出登录吗?"; -"Account configuration" = "账户设置"; -"Manage tags subscription" = "管理标签订阅"; -"Copy cookies" = "复制 Cookies"; - -"General" = "一般"; -"Navigation" = "导航"; -"Redirects links to the selected host" = "重定向链接到选定的站点"; -"Detects links from the clipboard" = "从剪切板检测链接"; -"Security" = "安全"; -"Auto-Lock" = "自动锁定"; -"App switcher blur" = "在应用切换器中模糊处理"; -"Cache" = "缓存"; -"Clear" = "清空"; -"Clear image caches" = "清空图片缓存"; -"Are you sure to clear?" = "确定要清空吗?"; - -"Appearance" = "外观"; -"Global" = "全局"; -"Theme" = "主题"; -"Tint Color" = "主题色"; -"App Icon" = "应用图标"; -"Translates tags" = "翻译标签"; -"List" = "列表"; -"Display mode" = "显示模式"; -"LIST_DISPLAY_MODE_DETAIL" = "详情"; -"LIST_DISPLAY_MODE_THUMBNAIL" = "缩略图"; -"Shows tags in list" = "在列表中显示标签"; -"Maximum number of tags" = "标签数量上限"; -"Infinity" = "无限"; - -"Reading" = "阅读"; -"Direction" = "方向"; -"READING_DIRECTION_VERTICAL" = "垂直"; -"Right-to-left" = "右至左"; -"Left-to-right" = "左至右"; -"Preload limit" = "预加载数量上限"; -"%lld pages" = "%lld 页"; -"%lld times" = "%lld 次"; -"Prefers landscape" = "偏好横屏"; -"Separator height" = "分隔线厚度"; -"Maximum scale factor" = "最大缩放系数"; -"Double tap scale factor" = "双击缩放系数"; -"Dual-page mode" = "双页模式"; -"Except the cover" = "封面除外"; - -"Laboratory" = "实验室"; -"Bypass SNI Filtering" = "域前置绕过 SNI 阻断"; - -"About EhPanda" = "关于 EhPanda"; -"Version" = "版本"; -"Website" = "网站"; -"AltStore Source" = "AltStore 源"; -"Acknowledgement" = "致谢"; +"reading.view.context.menu.button.reload" = "重新加载"; +"reading.view.context.menu.button.copy" = "复制"; +"reading.view.context.menu.button.save" = "保存"; +"reading.view.context.menu.button.saveOriginal" = "保存原图"; +"reading.view.context.menu.button.share" = "分享"; +"reading.view.toolbar.item.title.autoPlay" = "自动播放"; +"reading.view.toolbar.item.title.dualPageMode" = "双页模式"; +"reading.view.toolbar.item.title.exceptTheCover" = "封面除外"; +// AutoPlayPolicy +"enum.auto.play.policy.value.off" = "不启用"; + +// MARK: FiltersView +"filters.view.title.filters" = "筛选"; +"filters.view.title.advancedSettings" = "高级选项"; +"filters.view.title.searchGalleryName" = "搜索画廊名称"; +"filters.view.title.searchGalleryTags" = "搜索画廊标签"; +"filters.view.title.searchGalleryDescription" = "搜索画廊描述"; +"filters.view.title.searchTorrentFilenames" = "搜索种子文件名"; +"filters.view.title.onlyShowGalleriesWithTorrents" = "只显示带有种子的画廊"; +"filters.view.title.searchLowPowerTags" = "搜索低期望标签"; +"filters.view.title.searchDownvotedTags" = "搜索低评价标签"; +"filters.view.title.showExpungedGalleries" = "显示已被删除的画廊"; +"filters.view.title.setMinimumRating" = "设置评分下限"; +"filters.view.title.minimumRating" = "评分下限"; +"filters.view.title.setPagesRange" = "设置页数范围"; +"filters.view.title.pagesRange" = "页数范围"; +"filters.view.title.disableLanguageFilter" = "禁用语言筛选"; +"filters.view.title.disableUploaderFilter" = "禁用上传者筛选"; +"filters.view.title.disableTagsFilter" = "禁用标签筛选"; +"filters.view.button.resetFilters" = "重置所有选项"; +"filters.view.section.title.advanced" = "高级"; +"filters.view.section.title.defaultFilter" = "默认筛选"; +// FilterRange +"enum.filter.range.value.search" = "搜索"; +"enum.filter.range.value.global" = "全局"; +"enum.filter.range.value.watched" = "标签"; // MARK: EhSettingView -"Profile Settings" = "档案设置"; -"Selected profile" = "当前选定档案"; -"Set as default" = "设置为默认"; -"Delete profile" = "删除档案"; -"Are you sure to delete this profile?" = "确定要删除这个档案吗?"; -"Delete" = "删除"; -"Rename" = "重命名"; -"Create new" = "创建新档案"; - -"Image Load Settings" = "图片加载设置"; -"Recommended." = "推荐。"; -"Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports." = "可能稍慢。当防火墙或代理阻止非标准接口的流量时启用此项。"; -"Donator only. You will not be able to browse as many pages, enable only if having severe problems." = "仅限赞助者。配额消耗会加快,只有出现问题时才启用。"; -"Load images through the Hath network" = "通过 Hath 网络加载图像"; -"Any client" = "所有客户端"; -"Default port clients only" = "仅使用默认端口的客户端"; -"LOAD_THROUGH_HATH_NO" = "否"; -"You appear to be browsing the site from **PLACEHOLDER** or use a VPN or proxy in this country, which means the site will try to load images from Hath clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below." = "你似乎正在 **PLACEHOLDER** 浏览此网页,或是使用了一个来自这个国家的 VPN 或代理,这意味着网站将尝试通过在此区域的 Hath 客户端加载图片。如果该结果不正确,或你想通过其它地区的 Hath 客户端加载图片(例如你正在使用分割隧道 VPN),你可以在下方选择另一个国家。"; -"Browsing country" = "浏览国家"; - -"Image Size Settings" = "图片尺寸设置"; -"Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000." = "通常情况,图像将重采样到 1280 像素宽度以用于在线浏览,你也可以选择以下重新采样分辨率。但是为了避免负载过高,高于 1280 像素将只供给于赞助者、特殊贡献者,以及 UID 小于 3,000,000 的用户。"; -"Image resolution" = "图像分辨率"; -"Auto" = "自动"; -"While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)" = "虽然图片会自动根据窗口缩小,你也可以手动设置最大大小,图片并没有重新采样。(0 为不限制)"; -"Image size" = "图像尺寸"; -"Horizontal" = "水平"; -"Vertical" = "垂直"; - -"Gallery Name Display" = "画廊名称显示"; -"Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?" = "很多画廊都同时拥有英文或者日文标题,你想默认显示哪一个?"; -"Gallery name" = "画廊名称"; -"Default Title" = "默认标题"; -"Japanese Title (if available)" = "日文标题(如果有)"; - -"Archiver Settings" = "归档设置"; -"The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here." = "默认归档下载方式为手动选择(原画质或压缩画质),然后手动复制或点击下载链接。你可以修改归档下载方式。"; -"Archiver behavior" = "归档操作"; -"Manual Select, Manual Start (Default)" = "手动选择,手动下载(默认)"; -"Manual Select, Auto Start" = " 手动选择,自动下载"; -"Auto Select Original, Manual Start" = "自动选择原始画质,手动下载"; -"Auto Select Original, Auto Start" = "自动选择原始画质,自动下载"; -"Auto Select Resample, Manual Start" = "自动选择压缩画质,手动下载"; -"Auto Select Resample, Auto Start" = "自动选择压缩画质,自动下载"; - -"Front Page Settings" = "首页设置"; -"Which display mode would you like to use on the front and search pages?" = "你想在搜索页面显示哪种样式?"; -"Compact" = "紧凑"; -"Thumbnail" = "缩略图"; -"Extended" = "扩展"; -"Minimal" = "最小化"; -"Minimal+" = "最小化 +"; -"What categories would you like to show by default on the front page and in searches?" = "你希望在首页上看到哪些类别?"; - -"Here you can choose and rename your favorite categories." = "在这里你可以重命名你的收藏夹。"; -"You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting." = "你也可以选择收藏夹中默认排序。注意:2016 年 3 月改版之前加入收藏夹的画廊并未保存收藏时间,会以画廊发布时间代替。"; -"Favorites sort order" = "收藏排序方式"; -"By last gallery update time" = "按更新时间"; -"By favorited time" = "按收藏时间"; - -"Ratings" = "评分"; -"By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works." = "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。"; -"Ratings color" = "评分颜色"; - -"Tag Namespaces" = "标签命名空间"; -"If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces." = "如果要从默认标签搜索中排除某些命名空间,可以将以下内容标记为删除样式。注意:这不会阻止带有这些命名空间中标签的画廊出现,它只是在搜索标签时排除这些命名空间。"; - -"Tag Filtering Threshold" = "标签筛选阈值"; -"You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999." = "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。"; - -"Tag Watching Threshold" = "标签订阅阈值"; -"Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999." = "你可以通过将标签加入“我的标签”并设置一个正权重来关注它们。如果一个最近上传的作品所有标签的权重之和高于设定值,则它将会被包含在“关注”里。这个值可以设定为 0 ~ 9999。"; - -"Excluded Languages" = "屏蔽的语言"; -"If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query." = "如果你希望以从列表或搜索结果中隐藏特定语言的画廊,请从下面的列表中选择。注意:无论搜索条件为何,这些画廊都不会出现。"; -"Original" = "原始"; -"Translated" = "翻译版本"; -"Rewrite" = "改编版本"; - -"Excluded Uploaders" = "屏蔽的上传者"; -"If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query." = "如果你希望在画廊中和搜索中隐藏某个上传者的话,请把他们的用户名填写在下方,每行一个。注意:无论搜索条件为何,这些上传者都不会出现。"; -"You are currently using **%lld / 1000** exclusion slots." = "已使用 **%lld / 1000** 个屏蔽槽位。"; - -"Search Result Count" = "搜索结果数"; -"How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)" = "搜索页面每页显示多少条数据?\n(需要“Hath Perk:页面扩大”)"; -"Result count" = "结果数"; - -"Thumbnail Settings" = "缩略图设置"; -"How would you like the mouse-over thumbnails on the front page to load when using List Mode?" = "你希望鼠标悬停缩略图何时加载?"; -"Pages load faster, but there may be a slight delay before a thumb appears." = "页面加载快,缩略图加载有延迟。"; -"Pages take longer to load, but there is no delay for loading a thumb after the page has loaded." = "页面加载时间更长,显示缩略图无需等待。"; -"Thumbnail load timing" = "缩略图加载时机"; -"On mouse-over" = "鼠标悬停时"; -"On page load" = "页面加载时"; -"You can set a default thumbnail configuration for all galleries you visit." = "你可以设定一个对所有画廊生效的默认缩略图配置。"; -"Size" = "尺寸"; -"Large" = "大"; -"Rows" = "行数"; - -"Thumbnail Scaling" = "缩略图缩放"; -"Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%." = "缩略图和扩展模式下的画廊列表缩略图可以缩放为 75% 到 150% 之间的自定义值。"; -"Scale factor" = "缩放比例"; - -"Viewport Override" = "覆写可视区域"; -"Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400." = "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100% 缩略图比例下的合理值在 640 到 1400 之间。"; -"Virtual width" = "虚拟宽度"; - -"Gallery Comments" = "画廊评论"; -"Comments sort order" = "评论排序方式"; -"Oldest comments first" = "按最早的评论"; -"Recent comments first" = "按最新的评论"; -"By highest score" = "按最高分的评论"; -"Comment votes show timing" = "显示评论分数时机"; -"On score hover or click" = "悬停或点击时"; -"Always" = "始终显示"; - -"Gallery Tags" = "画廊标签"; -"Tags sort order" = "标签排序方式"; -"Alphabetical" = "按字母排序"; -"By tag power" = "按标签权重"; - -"Gallery Page Numbering" = "画廊页面页码"; -"Show gallery page numbers" = "显示画廊页码"; - -"Hath Local Network Host" = "Hath 本地网络服务器"; -"This setting can be used if you have a Hath client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work." = "如果你本地安装了 Hath 客户端,本地 IP 与浏览网站的公共 IP 相同,一些路由器不支持回流导致无法访问到自己,你可以设置这里来解决。\n如果在同一台设备上访问网站和运行客户端,请使用本地回环地址 (127.0.0.1:端口号)。如果客户端在网络上的其它设备运行,请使用那台机器的内网 IP。某些浏览器的配置可能阻止外部网站访问本地网络,你必须将网站列入白名单才能工作。"; -"IP address:Port" = "IP 地址:端口"; - -"Original Images" = "原始图像"; -"Use original images" = "显示原图"; -"Multi-Page Viewer" = "多页查看器"; -"Use Multi-Page Viewer" = "使用多页查看器"; -"Display style" = "显示样式"; -"Align left, scale if overwidth" = "左对齐,图像过宽时缩放"; -"Align center, scale if overwidth" = "居中对齐,图像过宽时缩放"; -"Align center, always scale" = "左对齐,图像始终缩放"; -"Show thumbnail pane" = "显示缩略图侧栏"; - -// MARK: LogsView -"Logs" = "日志"; -"Latest" = "最新"; -"%lld records" = "%lld 条记录"; - -// MARK: FilterView -"Filters" = "筛选"; -"Basic" = "基础"; -"Reset filters" = "重置所有选项"; -"Are you sure to reset?" = "确定要重置吗?"; -"Reset" = "重置"; -"Advanced settings" = "高级选项"; -"Advanced" = "高级"; -"Search gallery name" = "搜索画廊名称"; -"Search gallery tags" = "搜索画廊标签"; -"Search gallery description" = "搜索画廊描述"; -"Search torrent filenames" = "搜索种子文件名"; -"Only show galleries with torrents" = "只显示带有种子的画廊"; -"Search Low-Power tags" = "搜索低期望标签"; -"Search downvoted tags" = "搜索低评价标签"; -"Show expunged galleries" = "显示已被删除的画廊"; -"Set minimum rating" = "设定评分下限"; -"Minimum rating" = "评分下限"; -"%lld stars" = "%lld 星"; -"Set pages range" = "设定页数范围"; -"Pages range" = "页数范围"; -"Default Filter" = "默认筛选"; -"Disable language filter" = "禁用语言筛选"; -"Disable uploader filter" = "禁用上传者筛选"; -"Disable tags filter" = "禁用标签筛选"; - -// MARK: NewDawnView -"Show new dawn greeting" = "显示黎明问候"; -"It is the dawn of a new day!" = "又是新一天的黎明!"; -"Reflecting on your journey so far, you find that you are a little wiser." = "回顾至今的历程,发觉自己更睿智了一些。"; -"GAINCONTENT_START" = "你获得了"; -"GAINCONTENT_SEPARATOR" = "、"; -"GAINCONTENT_AND" = "和"; -"GAINCONTENT_END" = "!"; - -// MARK: QuickSearchView -"Quick search" = "快速搜索"; -"Alias" = "别称"; - -// MARK: HomeListType -"Search" = "搜索"; -"Frontpage" = "主页"; -"Popular" = "热门"; -"Watched" = "标签"; -"Favorites" = "收藏"; -"Toplists" = "排行"; -"Downloaded" = "下载"; -"History" = "历史"; - -// MARK: ToplistType -"All time" = "全部"; -"Past year" = "去年"; -"Past month" = "上月"; -"Yesterday" = "昨日"; +"eh.setting.view.title.hostSetting" = "%@ 设置"; +"eh.setting.view.section.title.profileSettings" = "档案设置"; +"eh.setting.view.title.selectedProfile" = "当前选定档案"; +"eh.setting.view.button.setAsDefault" = "设为默认"; +"eh.setting.view.button.deleteProfile" = "删除档案"; +"eh.setting.view.button.rename" = "重命名"; +"eh.setting.view.button.createNew" = "创建新档案"; +"eh.setting.view.toolbar.item.button.done" = "完成"; + +"eh.setting.view.section.title.imageLoadSettings" = "图片加载设置"; +"eh.setting.view.title.loadImagesThroughTheHathNetwork" = "通过 Hath 网络加载图像"; +"eh.setting.view.title.browsingCountry" = "浏览国家"; +"eh.setting.view.description.browsingCountry" = "你似乎正在 **%@** 浏览此网页,或是使用了一个来自这个国家的 VPN 或代理,这意味着网站将尝试通过在此区域的 H@H 客户端加载图片。如果该结果不正确,或你想通过其它地区的 H@H 客户端加载图片(例如你正在使用分割隧道 VPN),你可以在下方选择另一个国家。"; +// EhSetting.LoadThroughHathSetting +"enum.eh.setting.load.through.hath.setting.value.anyClient" = "所有客户端"; +"enum.eh.setting.load.through.hath.setting.value.defaultPortOnly" = "仅使用默认端口的客户端"; +"enum.eh.setting.load.through.hath.setting.value.modernNo" = "不通过 [现代 / HTTPS]"; +"enum.eh.setting.load.through.hath.setting.value.legacyNo" = "不通过 [旧式 / HTTP]"; +"enum.eh.setting.load.through.hath.setting.description.anyClient" = "推荐。"; +"enum.eh.setting.load.through.hath.setting.description.defaultPortOnly" = "可能稍慢。当防火墙或代理阻止非标准接口的流量时启用此项。"; +"enum.eh.setting.load.through.hath.setting.description.modernNo" = "仅限赞助者。配额消耗会加快。只建议在遇到严重问题时使用。"; +"enum.eh.setting.load.through.hath.setting.description.legacyNo" = "仅限赞助者。在现代浏览器可能不可用。只建议在旧式 / 过时的浏览器使用。"; + +"eh.setting.view.section.title.imageSizeSettings" = "图像尺寸设置"; +"eh.setting.view.title.imageResolution" = "图像分辨率"; +"eh.setting.view.description.imageResolution" = "通常情况,图像将重采样到 1280 像素宽度以用于在线浏览,你也可以选择以下重新采样分辨率。但是为了避免负载过高,高于 1280 像素将只供给于赞助者、特殊贡献者,以及 UID 小于 3,000,000 的用户。"; +"eh.setting.view.title.imageSize" = "图像尺寸"; +"eh.setting.view.description.imageSize" = "虽然图片会自动根据窗口缩小,你也可以手动设置最大大小,图片并没有重新采样。(0 为不限制)"; +"eh.setting.view.title.horizontal" = "宽度"; +"eh.setting.view.title.vertical" = "高度"; +// EhSetting.ImageResolution +"enum.eh.setting.image.resolution.value.auto" = "自动"; + +"eh.setting.view.section.title.galleryNameDisplay" = "画廊名称显示"; +"eh.setting.view.title.galleryName" = "画廊名称"; +"eh.setting.view.description.galleryName" = "很多画廊都同时拥有英文或者日文标题,你想默认显示哪一个?"; +// EhSetting.GalleryName +"enum.eh.setting.gallery.name.value.default" = "默认标题"; +"enum.eh.setting.gallery.name.value.japanese" = "日文标题(如果有)"; + +"eh.setting.view.section.title.archiverSettings" = "归档设置"; +"eh.setting.view.title.archiverBehavior" = "归档下载方式"; +"eh.setting.view.description.archiverBehavior" = "默认归档下载方式为手动选择(原画质或压缩画质),然后手动复制或点击下载链接。你可以修改归档下载方式。"; +// EhSetting.ArchiverBehavior +"enum.eh.setting.archiver.behavior.value.manualSelectManualStart" = "手动选择,手动下载(默认)"; +"enum.eh.setting.archiver.behavior.value.manualSelectAutoStart" = "手动选择,自动下载"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalManualStart" = "自动选择原始画质,手动下载"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalAutoStart" = "自动选择原始画质,自动下载"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleManualStart" = "自动选择压缩画质,手动下载"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleAutoStart" = "自动选择压缩画质,自动下载"; + +"eh.setting.view.section.title.frontPageSettings" = "扉页设置"; +"eh.setting.view.title.displayMode" = "显示样式"; +"eh.setting.view.description.displayMode" = "你希望在扉页和搜索页显示哪种样式?"; +"eh.setting.view.description.galleryCategory" = "你希望在扉页和搜索页看到哪些类别?"; +// EhSetting.DisplayMode +"enum.eh.setting.display.mode.value.compact" = "紧凑"; +"enum.eh.setting.display.mode.value.thumbnail" = "缩略图"; +"enum.eh.setting.display.mode.value.extended" = "扩展"; +"enum.eh.setting.display.mode.value.minimal" = "最小化"; +"enum.eh.setting.display.mode.value.minimalPlus" = "最小化 +"; + +"eh.setting.view.section.title.favorites" = "收藏"; +"eh.setting.view.description.favoriteCategories" = "在这里你可以重命名你的收藏夹。"; +"eh.setting.view.title.favoritesSortOrder" = "收藏排序方式"; +"eh.setting.view.description.favoritesSortOrder" = "你也可以选择收藏夹中默认排序。注意:2016 年 3 月改版之前加入收藏夹的画廊并未保存收藏时间,会以画廊发布时间代替。"; +// EhSetting.FavoritesSortOrder +"enum.eh.setting.favorites.sort.order.value.lastUpdateTime" = "按更新时间"; +"enum.eh.setting.favorites.sort.order.value.favoritedTime" = "按收藏时间"; + +"eh.setting.view.section.title.ratings" = "评分"; +"eh.setting.view.title.ratingsColor" = "评分颜色"; +"eh.setting.view.promt.ratingsColor" = "RRGGB"; +"eh.setting.view.description.ratingsColor" = "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。"; + +"eh.setting.view.section.title.tagsNamespaces" = "标签命名空间"; +"eh.setting.view.description.tagsNamespaces" = "如果要从默认标签搜索中排除某些命名空间,可以将以下内容标记为删除样式。注意:这不会阻止带有这些命名空间中标签的画廊出现,它只是在搜索标签时排除这些命名空间。"; + +"eh.setting.view.section.title.tagFilteringThreshold" = "标签筛选阈值"; +"eh.setting.view.title.tagFilteringThreshold" = "标签筛选阈值"; +"eh.setting.view.description.tagFilteringThreshold" = "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。"; + +"eh.setting.view.section.title.tagWatchingThreshold" = "标签订阅阈值"; +"eh.setting.view.title.tagWatchingThreshold" = "标签订阅阈值"; +"eh.setting.view.description.tagWatchingThreshold" = "你可以通过将标签加入“我的标签”并设置一个正权重来关注它们。如果一个最近上传的作品所有标签的权重之和高于设定值,则它将会被包含在“关注”里。这个值可以设定为 0 ~ 9999。"; + +"eh.setting.view.section.title.excludedLanguages" = "屏蔽的语言"; +"eh.setting.view.description.excludedLanguages" = "如果你希望以从列表或搜索结果中隐藏特定语言的画廊,请从下面的列表中选择。注意:无论搜索条件为何,这些画廊都不会出现。"; +// EhSetting.ExcludedLanguagesCategory +"enum.eh.setting.excluded.languages.category.value.original" = "原始版本"; +"enum.eh.setting.excluded.languages.category.value.translated" = "翻译版本"; +"enum.eh.setting.excluded.languages.category.value.rewrite" = "改编版本"; + +"eh.setting.view.section.title.excludedUploaders" = "屏蔽的上传者"; +"eh.setting.view.description.excludedUploaders" = "如果你希望在画廊中和搜索中隐藏某个上传者的话,请把他们的用户名填写在下方,每行一个。注意:无论搜索条件为何,这些上传者都不会出现。"; +"eh.setting.view.description.excludedUploadersCount" = "已使用 **%@ / %@** 个屏蔽槽位。"; + +"eh.setting.view.section.title.searchResultCount" = "搜索结果数"; +"eh.setting.view.title.resultCount" = "结果数"; +"eh.setting.view.description.resultCount" = "搜索页面每页显示多少条数据?\n(需要“Hath Perk:页面扩大”)"; + +"eh.setting.view.section.title.thumbnailSettings" = "缩略图设置"; +"eh.setting.view.title.thumbnailLoadTiming" = "缩略图加载时机"; +"eh.setting.view.description.thumbnailLoadTiming" = "你希望列表中的鼠标悬停缩略图何时加载?"; +"eh.setting.view.description.thumbnailConfiguration" = "你可以设定一个对所有画廊生效的默认缩略图配置。"; +"eh.setting.view.title.thumbnailSize" = "尺寸"; +"eh.setting.view.title.thumbnailRowCount" = "行数"; +// EhSetting.ThumbnailLoadTiming +"enum.eh.setting.thumbnail.load.timing.value.onMouseOver" = "鼠标悬停时"; +"enum.eh.setting.thumbnail.load.timing.value.onPageLoad" = "页面加载时"; +"enum.eh.setting.thumbnail.load.timing.description.onMouseOver" = "页面加载快,缩略图加载有延迟。"; +"enum.eh.setting.thumbnail.load.timing.description.onPageLoad" = "页面加载时间更长,显示缩略图无需等待。"; +// EhSetting.ThumbnailSize +"enum.eh.setting.thumbnail.size.value.normal" = "普通"; +"enum.eh.setting.thumbnail.size.value.large" = "较大"; + +"eh.setting.view.section.title.thumbnailScaling" = "缩略图缩放"; +"eh.setting.view.title.scaleFactor" = "缩放比例"; +"eh.setting.view.description.scaleFactor" = "缩略图和扩展模式下的画廊列表缩略图可以缩放为 75% 到 150% 之间的自定义值。"; + +"eh.setting.view.section.title.viewportOverride" = "覆写可视区域"; +"eh.setting.view.title.virtualWidth" = "虚拟宽度"; +"eh.setting.view.description.virtualWidth" = "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100% 缩略图比例下的合理值在 640 到 1400 之间。"; + +"eh.setting.view.section.title.galleryComments" = "画廊评论"; +"eh.setting.view.title.commentsSortOrder" = "评论排序方式"; +"eh.setting.view.title.commentsVotesShowTiming" = "显示评论分数时机"; +// EhSetting.CommentsSortOrder +"enum.eh.setting.comments.sort.order.value.oldest" = "按最早的评论"; +"enum.eh.setting.comments.sort.order.value.recent" = "按最新的评论"; +"enum.eh.setting.comments.sort.order.value.highestScore" = "按最高分的评论"; +// EhSetting.CommentVotesShowTiming +"enum.eh.setting.comments.votes.show.timing.value.onHoverOrClick" = "悬停或点击时"; +"enum.eh.setting.comments.votes.show.timing.value.always" = "始终显示"; + +"eh.setting.view.section.title.galleryTags" = "画廊标签"; +"eh.setting.view.title.tagsSortOrder" = "标签排序方式"; +// EhSetting.TagsSortOrder +"enum.eh.setting.tags.sort.order.value.alphabetical" = "按字母排序"; +"enum.eh.setting.tags.sort.order.value.tagPower" = "按标签权重"; + +"eh.setting.view.section.title.galleryPageNumbering" = "画廊页面页码"; +"eh.setting.view.title.showGalleryPageNumbers" = "显示画廊页码"; + +"eh.setting.view.section.title.hathLocalNetworkHost" = "Hath 本地网络服务器"; +"eh.setting.view.title.ipAddressPort" = "IP 地址:端口"; +"eh.setting.view.description.ipAddressPort" = "如果你本地安装了 H@H 客户端,本地 IP 与浏览网站的公共 IP 相同,一些路由器不支持回流导致无法访问到自己,你可以设置这里来解决。\n如果在同一台设备上访问网站和运行客户端,请使用本地回环地址 (127.0.0.1:端口号)。如果客户端在网络上的其它设备运行,请使用那台机器的内网 IP。某些浏览器的配置可能阻止外部网站访问本地网络,你必须将网站列入白名单才能工作。"; + +"eh.setting.view.section.title.originalImages" = "原始图像"; +"eh.setting.view.title.useOriginalImages" = "显示原图"; + +"eh.setting.view.section.title.multiPageViewer" = "多页查看器"; +"eh.setting.view.title.useMultiPageViewer" = "使用多页查看器"; +"eh.setting.view.title.displayStyle" = "显示样式"; +"eh.setting.view.title.showThumbnailPane" = "显示缩略图侧栏"; +// EhSetting.MultiplePageViewerStyle +"enum.eh.setting.multiple.page.viewer.style.value.alignLeftScaleIfOverWidth" = "左对齐,图像过宽时缩放"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterScaleIfOverWidth" = "居中对齐,图像过宽时缩放"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterAlwaysScale" = "居中对齐,图像始终缩放"; // MARK: Category -"Doujinshi" = "同人志"; -"Manga" = "漫画"; -"Artist CG" = "插画"; -"Game CG" = "游戏 CG"; -"Western" = "西方"; -"Non-H" = "健康"; -"Image Set" = "照片集"; -"Cosplay" = "角色扮演"; -"Asian Porn" = "亚洲"; -"Misc" = "其它"; -"Private" = "非公开"; +"enum.category.value.doujinshi" = "同人志"; +"enum.category.value.manga" = "漫画"; +"enum.category.value.artistCG" = "插画"; +"enum.category.value.gameCG" = "游戏 CG"; +"enum.category.value.western" = "西方"; +"enum.category.value.nonH" = "健康"; +"enum.category.value.imageSet" = "照片集"; +"enum.category.value.cosplay" = "角色扮演"; +"enum.category.value.asianPorn" = "亚洲"; +"enum.category.value.misc" = "其它"; +"enum.category.value.private" = "非公开"; // MARK: TagCategory -"Reclass" = "归类"; -"Language" = "语言"; -"Parody" = "原作"; -"Character" = "角色"; -"Group" = "团体"; -"Artist" = "作者"; -"Male" = "男性"; -"Female" = "女性"; -"Mixed" = "混合性别"; -"Cosplayer" = "扮装者"; -"Other" = "其它"; -"Temp" = "临时"; - -// MARK: IconType -"Normal" = "普通"; -"Default" = "默认"; -"Weird" = "诡异"; - -// MARK: PreferredColorScheme -"Automatic" = "自动"; -"Light" = "浅色"; -"Dark" = "深色"; - -// MARK: AutoLockPolicy -"Never" = "不锁定"; -"Instantly" = "立即"; -"%lld seconds" = "%lld 秒"; -"%lld minute" = "%lld 分钟"; -"%lld minutes" = "%lld 分钟"; +"enum.tag.category.value.reclass" = "重归类"; +"enum.tag.category.value.language" = "语言"; +"enum.tag.category.value.parody" = "原作"; +"enum.tag.category.value.character" = "角色"; +"enum.tag.category.value.group" = "团体"; +"enum.tag.category.value.artist" = "作者"; +"enum.tag.category.value.male" = "男性"; +"enum.tag.category.value.female" = "女性"; +"enum.tag.category.value.mixed" = "混合性别"; +"enum.tag.category.value.cosplayer" = "扮装者"; +"enum.tag.category.value.other" = "其它"; +"enum.tag.category.value.temp" = "临时"; // MARK: Language -"LANGUAGE_OTHER" = "其它"; -"LANGUAGE_INVALID" = "无效"; - -"Afrikaans" = "南非语"; "Albanian" = "阿尔巴尼亚语"; "Arabic" = "阿拉伯语"; - -"Bengali" = "孟加拉语"; "Bosnian" = "波斯尼亚语"; "Bulgarian" = "保加利亚语"; "Burmese" = "缅甸语"; - -"Catalan" = "加泰罗尼亚语"; "Cebuano" = "宿雾語"; "Chinese" = "汉语"; "Croatian" = "克罗地亚语"; "Czech" = "捷克语"; - -"Danish" = "丹麦语"; "Dutch" = "荷兰语"; - -"English" = "英语"; "Esperanto" = "国际语"; "Estonian" = "爱沙尼亚语"; - -"Finnish" = "芬兰语"; "French" = "法语"; - -"Georgian" = "格鲁吉亚语"; "German" = "德语"; "Greek" = "希腊语"; - -"Hebrew" = "希伯来语"; "Hindi" = "印地语"; "Hmong" = "苗语"; "Hungarian" = "匈牙利语"; - -"Indonesian" = "印度尼西亚语"; "Italian" = "意大利语"; - -"Japanese" = "日语"; - -"Kazakh" = "哈萨克语"; "Khmer" = "高棉文"; "Korean" = "韩语"; "Kurdish" = "库尔德语"; - -"Lao" = "老挝语"; "Latin" = "拉丁语"; - -"Mongolian" = "蒙古语"; - -"Ndebele" = "恩德贝莱语"; "Nepali" = "尼泊尔语"; "Norwegian" = "挪威语"; - -"Oromo" = "奥罗莫语"; - -"Pashto" = "普什图语"; "Persian" = "波斯语"; "Polish" = "波兰语"; "Portuguese" = "葡萄牙语"; "Punjabi" = "旁遮普语"; - -"Romanian" = "罗马尼亚语"; "Russian" = "俄语"; - -"Sango" = "桑戈语"; "Serbian" = "塞尔维亚语"; "Shona" = "绍纳语"; "Slovak" = "斯洛伐克语"; "Slovenian" = "斯洛文尼亚语"; "Somali" = "索马里语"; "Spanish" = "西班牙语"; "Swahili" = "斯瓦希里语"; "Swedish" = "瑞典语"; - -"Tagalog" = "他加洛语"; "Thai" = "泰语"; "Tigrinya" = "提格利尼亚语"; "Turkish" = "土耳其语"; - -"Ukrainian" = "乌克兰语"; "Urdu" = "乌尔都语"; - -"Vietnamese" = "越南语"; - -"Zulu" = "祖鲁语"; - -// MARK: EhSettingCountry -"Auto-Detect" = "自动检测"; "Afghanistan" = "阿富汗"; "Aland Islands" = "奥兰群岛"; "Albania" = "阿尔巴尼亚"; "Algeria" = "阿尔及利亚"; "American Samoa" = "美属萨摩亚"; "Andorra" = "安道尔"; "Angola" = "安哥拉"; "Anguilla" = "安圭拉"; "Antarctica" = "南极洲"; "Antigua and Barbuda" = "安提瓜和巴布达"; "Argentina" = "阿根廷"; "Armenia" = "亚美尼亚"; "Aruba" = "阿鲁巴"; "Asia-Pacific Region" = "亚太地区"; "Australia" = "澳大利亚"; "Austria" = "奥地利"; "Azerbaijan" = "阿塞拜疆"; "Bahamas" = "巴哈马"; "Bahrain" = "巴林"; "Bangladesh" = "孟加拉国"; "Barbados" = "巴巴多斯"; "Belarus" = "白俄罗斯"; "Belgium" = "比利时"; "Belize" = "伯利兹"; "Benin" = "贝宁"; "Bermuda" = "百慕大"; "Bhutan" = "不丹"; "Bolivia" = "玻利维亚"; "Bonaire Saint Eustatius and Saba" = "博奈尔、圣尤斯特歇斯与萨巴"; "Bosnia and Herzegovina" = "波斯尼亚和黑塞哥维那"; "Botswana" = "博茨瓦纳"; "Bouvet Island" = "布韦岛"; "Brazil" = "巴西"; "British Indian Ocean Territory" = "英属印度洋领地"; "Brunei Darussalam" = "文莱"; "Bulgaria" = "保加利亚"; "Burkina Faso" = "布基纳法索"; "Burundi" = "蒲隆地"; "Cambodia" = "柬埔寨"; "Cameroon" = "喀麦隆"; "Canada" = "加拿大"; "Cape Verde" = "佛得角"; "Cayman Islands" = "开曼群岛"; "Central African Republic" = "中非"; "Chad" = "乍得"; "Chile" = "智利"; "China" = "中华人民共和国"; "Christmas Island" = "圣诞岛"; "Cocos Islands" = "科科斯岛"; "Colombia" = "哥伦比亚"; "Comoros" = "科摩罗"; "Congo" = "刚果共和国"; "The Democratic Republic of the Congo" = "刚果民主共和国"; "Cook Islands" = "库克群岛"; "Costa Rica" = "哥斯达黎加"; "Cote D'Ivoire" = "科特迪瓦"; "Croatia" = "克罗地亚"; "Cuba" = "古巴"; "Curacao" = "库拉索"; "Cyprus" = "塞浦路斯"; "Czech Republic" = "捷克"; "Denmark" = "丹麦"; "Djibouti" = "吉布提"; "Dominica" = "多米尼克"; "Dominican Republic" = "多米尼加"; "Ecuador" = "厄瓜多尔"; "Egypt" = "埃及"; "El Salvador" = "萨尔瓦多"; "Equatorial Guinea" = "赤道几内亚"; "Eritrea" = "厄立特里亚"; "Estonia" = "爱沙尼亚"; "Ethiopia" = "埃塞俄比亚"; "Europe" = "欧洲"; "Falkland Islands" = "福克兰群岛"; "Faroe Islands" = "法罗群岛"; "Fiji" = "斐济"; "Finland" = "芬兰"; "France" = "法国"; "French Guiana" = "法属圭亚那"; "French Polynesia" = "法属波利尼西亚"; "French Southern Territories" = "法属南部和南极领地"; "Gabon" = "加蓬"; "Gambia" = "冈比亚"; "Georgia" = "格鲁吉亚"; "Germany" = "德国"; "Ghana" = "加纳"; "Gibraltar" = "直布罗陀"; "Greece" = "希腊"; "Greenland" = "格陵兰"; "Grenada" = "格林纳达"; "Guadeloupe" = "瓜德罗普"; "Guam" = "关岛"; "Guatemala" = "危地马拉"; "Guernsey" = "根西"; "Guinea" = "几内亚"; "Guinea-Bissau" = "几内亚比绍"; "Guyana" = "圭亚那"; "Haiti" = "海地"; "Heard Island and McDonald Islands" = "赫德岛和麦克唐纳群岛"; "Vatican City State" = "梵蒂冈城国"; "Honduras" = "洪都拉斯"; "Hong Kong" = "香港"; "Hungary" = "匈牙利"; "Iceland" = "冰岛"; "India" = "印度"; "Indonesia" = "印度尼西亚"; "Iran" = "伊朗"; "Iraq" = "伊拉克"; "Ireland" = "爱尔兰"; "Isle of Man" = "曼岛"; "Israel" = "以色列"; "Italy" = "意大利"; "Jamaica" = "牙买加"; "Japan" = "日本"; "Jersey" = "泽西"; "Jordan" = "约旦"; "Kazakhstan" = "哈萨克斯坦"; "Kenya" = "肯尼亚"; "Kiribati" = "基里巴斯"; "Kuwait" = "科威特"; "Kyrgyzstan" = "吉尔吉斯斯坦"; "Lao People's Democratic Republic" = "老挝"; "Latvia" = "拉脱维亚"; "Lebanon" = "黎巴嫩"; "Lesotho" = "莱索托"; "Liberia" = "利比里亚"; "Libya" = "利比亚"; "Liechtenstein" = "列支敦士登"; "Lithuania" = "立陶宛"; "Luxembourg" = "卢森堡"; "Macau" = "澳门"; "Macedonia" = "马其顿"; "Madagascar" = "马达加斯加"; "Malawi" = "马拉维"; "Malaysia" = "马来西亚"; "Maldives" = "马尔代夫"; "Mali" = "马里"; "Malta" = "马耳他"; "Marshall Islands" = "马绍尔群岛"; "Martinique" = "马提尼克"; "Mauritania" = "毛里塔尼亚"; "Mauritius" = "模里西斯"; "Mayotte" = "马约特"; "Mexico" = "墨西哥"; "Micronesia" = "密克罗尼西亚"; "Moldova" = "摩尔多瓦"; "Monaco" = "摩纳哥"; "Mongolia" = "蒙古"; "Montenegro" = "黑山"; "Montserrat" = "蒙塞拉特岛"; "Morocco" = "摩洛哥"; "Mozambique" = "莫桑比克"; "Myanmar" = "缅甸"; "Namibia" = "纳米比亚"; "Nauru" = "诺鲁"; "Nepal" = "尼泊尔"; "Netherlands" = "荷兰"; "New Caledonia" = "新喀里多尼亚"; "New Zealand" = "新西兰"; "Nicaragua" = "尼加拉瓜"; "Niger" = "尼日尔"; "Nigeria" = "尼日利亚"; "Niue" = "纽埃"; "Norfolk Island" = "诺福克岛"; "North Korea" = "朝鲜"; "Northern Mariana Islands" = "北马里亚纳群岛"; "Norway" = "挪威"; "Oman" = "阿曼"; "Pakistan" = "巴基斯坦"; "Palau" = "帛琉"; "Palestinian Territory" = "巴勒斯坦"; "Panama" = "巴拿马"; "Papua New Guinea" = "巴布亚新几内亚"; "Paraguay" = "巴拉圭"; "Peru" = "秘鲁"; "Philippines" = "菲律宾"; "Pitcairn Islands" = "皮特凯恩群岛"; "Poland" = "波兰"; "Portugal" = "葡萄牙"; "Puerto Rico" = "波多黎各"; "Qatar" = "卡塔尔"; "Reunion" = "留尼汪"; "Romania" = ""; "Russian Federation" = "俄罗斯"; "Rwanda" = "卢旺达"; "Saint Barthelemy" = "圣巴泰勒米"; "Saint Helena" = "圣赫勒拿"; "Saint Kitts and Nevis" = "圣基茨岛"; "Saint Lucia" = "圣卢西亚"; "Saint Martin" = "圣马丁岛"; "Saint Pierre and Miquelon" = "圣皮埃尔和密克隆"; "Saint Vincent and the Grenadines" = "圣文森特和格林纳丁斯"; "Samoa" = "萨摩亚"; "San Marino" = "圣马力诺"; "Sao Tome and Principe" = "圣多美和普林西比"; "Saudi Arabia" = "沙地阿拉伯"; "Senegal" = "塞内加尔"; "Serbia" = "塞尔维亚"; "Seychelles" = "塞舌尔"; "Sierra Leone" = "塞拉利昂"; "Singapore" = "新加坡"; "Sint Maarten" = "圣马丁岛"; "Slovakia" = "斯洛伐克"; "Slovenia" = "斯洛文尼亚"; "Solomon Islands" = "所罗门群岛"; "Somalia" = "索马里"; "South Africa" = "南非"; "South Georgia and the South Sandwich Islands" = "南乔治亚和南桑威奇群岛"; "South Korea" = "韩国"; "South Sudan" = "南苏丹"; "Spain" = "西班牙"; "Sri Lanka" = "斯里兰卡"; "Sudan" = "苏丹"; "Suriname" = "苏里南"; "Svalbard and Jan Mayen" = "斯瓦尔巴和扬马延"; "Swaziland" = "史瓦帝尼"; "Sweden" = "瑞典"; "Switzerland" = "瑞士"; "Syrian Arab Republic" = "叙利亚"; "Taiwan" = "台湾"; "Tajikistan" = "塔吉克斯坦"; "Tanzania" = "坦桑尼亚"; "Thailand" = "泰国"; "Timor-Leste" = "东帝汶"; "Togo" = "多哥"; "Tokelau" = "托克劳"; "Tonga" = "汤加"; "Trinidad and Tobago" = "特立尼达和多巴哥"; "Tunisia" = "突尼斯"; "Turkey" = "土耳其"; "Turkmenistan" = "土库曼斯坦"; "Turks and Caicos Islands" = "特克斯和凯科斯群岛"; "Tuvalu" = "图瓦卢"; "Uganda" = "乌干达"; "Ukraine" = "乌克兰"; "United Arab Emirates" = "阿拉伯联合酋长国"; "United Kingdom" = "英国"; "United States" = "美国"; "United States Minor Outlying Islands" = "美国本土外小岛屿"; "Uruguay" = "乌拉圭"; "Uzbekistan" = "乌兹别克斯坦"; "Vanuatu" = "瓦努阿图"; "Venezuela" = "委內瑞拉"; "Vietnam" = "越南"; "British Virgin Islands" = "英属维尔京群岛"; "U.S. Virgin Islands" = "美属维尔京群岛"; "Wallis and Futuna" = "瓦利斯和富图纳"; "Western Sahara" = "西撒哈拉"; "Yemen" = "也门"; "Zambia" = "赞比亚"; "Zimbabwe" = "津巴布韦"; +"enum.language.value.invalid" = "无效"; +"enum.language.value.other" = "其它"; +"enum.language.value.afrikaans" = "南非语"; +"enum.language.value.albanian" = "阿尔巴尼亚语"; +"enum.language.value.arabic" = "阿拉伯语"; +"enum.language.value.bengali" = "孟加拉语"; +"enum.language.value.bosnian" = "波斯尼亚语"; +"enum.language.value.bulgarian" = "保加利亚语"; +"enum.language.value.burmese" = "缅甸语"; +"enum.language.value.catalan" = "加泰罗尼亚语"; +"enum.language.value.cebuano" = "宿雾語"; +"enum.language.value.chinese" = "汉语"; +"enum.language.value.croatian" = "克罗地亚语"; +"enum.language.value.czech" = "捷克语"; +"enum.language.value.danish" = "丹麦语"; +"enum.language.value.dutch" = "荷兰语"; +"enum.language.value.english" = "英语"; +"enum.language.value.esperanto" = "国际语"; +"enum.language.value.estonian" = "爱沙尼亚语"; +"enum.language.value.finnish" = "芬兰语"; +"enum.language.value.french" = "法语"; +"enum.language.value.georgian" = "格鲁吉亚语"; +"enum.language.value.german" = "德语"; +"enum.language.value.greek" = "希腊语"; +"enum.language.value.hebrew" = "希伯来语"; +"enum.language.value.hindi" = "印地语"; +"enum.language.value.hmong" = "苗语"; +"enum.language.value.hungarian" = "匈牙利语"; +"enum.language.value.indonesian" = "印度尼西亚语"; +"enum.language.value.italian" = "意大利语"; +"enum.language.value.japanese" = "日语"; +"enum.language.value.kazakh" = "哈萨克语"; +"enum.language.value.khmer" = "高棉文"; +"enum.language.value.korean" = "韩语"; +"enum.language.value.kurdish" = "库尔德语"; +"enum.language.value.lao" = "老挝语"; +"enum.language.value.latin" = "拉丁语"; +"enum.language.value.mongolian" = "蒙古语"; +"enum.language.value.ndebele" = "恩德贝莱语"; +"enum.language.value.nepali" = "尼泊尔语"; +"enum.language.value.norwegian" = "挪威语"; +"enum.language.value.oromo" = "奥罗莫语"; +"enum.language.value.pashto" = "普什图语"; +"enum.language.value.persian" = "波斯语"; +"enum.language.value.polish" = "波兰语"; +"enum.language.value.portuguese" = "葡萄牙语"; +"enum.language.value.punjabi" = "旁遮普语"; +"enum.language.value.romanian" = "罗马尼亚语"; +"enum.language.value.russian" = "俄语"; +"enum.language.value.sango" = "桑戈语"; +"enum.language.value.serbian" = "塞尔维亚语"; +"enum.language.value.shona" = "绍纳语"; +"enum.language.value.slovak" = "斯洛伐克语"; +"enum.language.value.slovenian" = "斯洛文尼亚语"; +"enum.language.value.somali" = "索马里语"; +"enum.language.value.spanish" = "西班牙语"; +"enum.language.value.swahili" = "斯瓦希里语"; +"enum.language.value.swedish" = "瑞典语"; +"enum.language.value.tagalog" = "他加洛语"; +"enum.language.value.thai" = "泰语"; +"enum.language.value.tigrinya" = "提格利尼亚语"; +"enum.language.value.turkish" = "土耳其语"; +"enum.language.value.ukrainian" = "乌克兰语"; +"enum.language.value.urdu" = "乌尔都语"; +"enum.language.value.vietnamese" = "越南语"; +"enum.language.value.zulu" = "祖鲁语"; + +// MARK: BrowsingCountry +"enum.browsing.country.name.autoDetect" = "自动检测"; +"enum.browsing.country.name.afghanistan" = "阿富汗"; +"enum.browsing.country.name.alandIslands" = "奥兰群岛"; +"enum.browsing.country.name.albania" = "阿尔巴尼亚"; +"enum.browsing.country.name.algeria" = "阿尔及利亚"; +"enum.browsing.country.name.americanSamoa" = "美属萨摩亚"; +"enum.browsing.country.name.andorra" = "安道尔"; +"enum.browsing.country.name.angola" = "安哥拉"; +"enum.browsing.country.name.anguilla" = "安圭拉"; +"enum.browsing.country.name.antarctica" = "南极洲"; +"enum.browsing.country.name.antiguaAndBarbuda" = "安提瓜和巴布达"; +"enum.browsing.country.name.argentina" = "阿根廷"; +"enum.browsing.country.name.armenia" = "亚美尼亚"; +"enum.browsing.country.name.aruba" = "阿鲁巴"; +"enum.browsing.country.name.asiaPacificRegion" = "亚太地区"; +"enum.browsing.country.name.australia" = "澳大利亚"; +"enum.browsing.country.name.austria" = "奥地利"; +"enum.browsing.country.name.azerbaijan" = "阿塞拜疆"; +"enum.browsing.country.name.bahamas" = "巴哈马"; +"enum.browsing.country.name.bahrain" = "巴林"; +"enum.browsing.country.name.bangladesh" = "孟加拉国"; +"enum.browsing.country.name.barbados" = "巴巴多斯"; +"enum.browsing.country.name.belarus" = "白俄罗斯"; +"enum.browsing.country.name.belgium" = "比利时"; +"enum.browsing.country.name.belize" = "伯利兹"; +"enum.browsing.country.name.benin" = "贝宁"; +"enum.browsing.country.name.bermuda" = "百慕大"; +"enum.browsing.country.name.bhutan" = "不丹"; +"enum.browsing.country.name.bolivia" = "玻利维亚"; +"enum.browsing.country.name.bonaireSaintEustatiusAndSaba" = "博奈尔、圣尤斯特歇斯与萨巴"; +"enum.browsing.country.name.bosniaAndHerzegovina" = "波斯尼亚和黑塞哥维那"; +"enum.browsing.country.name.botswana" = "博茨瓦纳"; +"enum.browsing.country.name.bouvetIsland" = "布韦岛"; +"enum.browsing.country.name.brazil" = "巴西"; +"enum.browsing.country.name.britishIndianOceanTerritory" = "英属印度洋领地"; +"enum.browsing.country.name.bruneiDarussalam" = "文莱"; +"enum.browsing.country.name.bulgaria" = "保加利亚"; +"enum.browsing.country.name.burkinaFaso" = "布基纳法索"; +"enum.browsing.country.name.burundi" = "蒲隆地"; +"enum.browsing.country.name.cambodia" = "柬埔寨"; +"enum.browsing.country.name.cameroon" = "喀麦隆"; +"enum.browsing.country.name.canada" = "加拿大"; +"enum.browsing.country.name.capeVerde" = "佛得角"; +"enum.browsing.country.name.caymanIslands" = "开曼群岛"; +"enum.browsing.country.name.centralAfricanRepublic" = "中非"; +"enum.browsing.country.name.chad" = "乍得"; +"enum.browsing.country.name.chile" = "智利"; +"enum.browsing.country.name.china" = "中华人民共和国"; +"enum.browsing.country.name.christmasIsland" = "圣诞岛"; +"enum.browsing.country.name.cocosIslands" = "科科斯岛"; +"enum.browsing.country.name.colombia" = "哥伦比亚"; +"enum.browsing.country.name.comoros" = "科摩罗"; +"enum.browsing.country.name.congo" = "刚果共和国"; +"enum.browsing.country.name.theDemocraticRepublicOfTheCongo" = "刚果民主共和国"; +"enum.browsing.country.name.cookIslands" = "库克群岛"; +"enum.browsing.country.name.costaRica" = "哥斯达黎加"; +"enum.browsing.country.name.coteDIvoire" = "科特迪瓦"; +"enum.browsing.country.name.croatia" = "克罗地亚"; +"enum.browsing.country.name.cuba" = "古巴"; +"enum.browsing.country.name.curacao" = "库拉索"; +"enum.browsing.country.name.cyprus" = "塞浦路斯"; +"enum.browsing.country.name.czechRepublic" = "捷克"; +"enum.browsing.country.name.denmark" = "丹麦"; +"enum.browsing.country.name.djibouti" = "吉布提"; +"enum.browsing.country.name.dominica" = "多米尼克"; +"enum.browsing.country.name.dominicanRepublic" = "多米尼加"; +"enum.browsing.country.name.ecuador" = "厄瓜多尔"; +"enum.browsing.country.name.egypt" = "埃及"; +"enum.browsing.country.name.elSalvador" = "萨尔瓦多"; +"enum.browsing.country.name.equatorialGuinea" = "赤道几内亚"; +"enum.browsing.country.name.eritrea" = "厄立特里亚"; +"enum.browsing.country.name.estonia" = "爱沙尼亚"; +"enum.browsing.country.name.ethiopia" = "埃塞俄比亚"; +"enum.browsing.country.name.europe" = "欧洲"; +"enum.browsing.country.name.falklandIslands" = "福克兰群岛"; +"enum.browsing.country.name.faroeIslands" = "法罗群岛"; +"enum.browsing.country.name.fiji" = "斐济"; +"enum.browsing.country.name.finland" = "芬兰"; +"enum.browsing.country.name.france" = "法国"; +"enum.browsing.country.name.frenchGuiana" = "法属圭亚那"; +"enum.browsing.country.name.frenchPolynesia" = "法属波利尼西亚"; +"enum.browsing.country.name.frenchSouthernTerritories" = "法属南部和南极领地"; +"enum.browsing.country.name.gabon" = "加蓬"; +"enum.browsing.country.name.gambia" = "冈比亚"; +"enum.browsing.country.name.georgia" = "格鲁吉亚"; +"enum.browsing.country.name.germany" = "德国"; +"enum.browsing.country.name.ghana" = "加纳"; +"enum.browsing.country.name.gibraltar" = "直布罗陀"; +"enum.browsing.country.name.greece" = "希腊"; +"enum.browsing.country.name.greenland" = "格陵兰"; +"enum.browsing.country.name.grenada" = "格林纳达"; +"enum.browsing.country.name.guadeloupe" = "瓜德罗普"; +"enum.browsing.country.name.guam" = "关岛"; +"enum.browsing.country.name.guatemala" = "危地马拉"; +"enum.browsing.country.name.guernsey" = "根西"; +"enum.browsing.country.name.guinea" = "几内亚"; +"enum.browsing.country.name.guineaBissau" = "几内亚比绍"; +"enum.browsing.country.name.guyana" = "圭亚那"; +"enum.browsing.country.name.haiti" = "海地"; +"enum.browsing.country.name.heardIslandAndMcDonaldIslands" = "赫德岛和麦克唐纳群岛"; +"enum.browsing.country.name.vaticanCityState" = "梵蒂冈城国"; +"enum.browsing.country.name.honduras" = "洪都拉斯"; +"enum.browsing.country.name.hongKong" = "香港"; +"enum.browsing.country.name.hungary" = "匈牙利"; +"enum.browsing.country.name.iceland" = "冰岛"; +"enum.browsing.country.name.india" = "印度"; +"enum.browsing.country.name.indonesia" = "印度尼西亚"; +"enum.browsing.country.name.iran" = "伊朗"; +"enum.browsing.country.name.iraq" = "伊拉克"; +"enum.browsing.country.name.ireland" = "爱尔兰"; +"enum.browsing.country.name.isleOfMan" = "曼岛"; +"enum.browsing.country.name.israel" = "以色列"; +"enum.browsing.country.name.italy" = "意大利"; +"enum.browsing.country.name.jamaica" = "牙买加"; +"enum.browsing.country.name.japan" = "日本"; +"enum.browsing.country.name.jersey" = "泽西"; +"enum.browsing.country.name.jordan" = "约旦"; +"enum.browsing.country.name.kazakhstan" = "哈萨克斯坦"; +"enum.browsing.country.name.kenya" = "肯尼亚"; +"enum.browsing.country.name.kiribati" = "基里巴斯"; +"enum.browsing.country.name.kuwait" = "科威特"; +"enum.browsing.country.name.kyrgyzstan" = "吉尔吉斯斯坦"; +"enum.browsing.country.name.laoPeoplesDemocraticRepublic" = "老挝"; +"enum.browsing.country.name.latvia" = "拉脱维亚"; +"enum.browsing.country.name.lebanon" = "黎巴嫩"; +"enum.browsing.country.name.lesotho" = "莱索托"; +"enum.browsing.country.name.liberia" = "利比里亚"; +"enum.browsing.country.name.libya" = "利比亚"; +"enum.browsing.country.name.liechtenstein" = "列支敦士登"; +"enum.browsing.country.name.lithuania" = "立陶宛"; +"enum.browsing.country.name.luxembourg" = "卢森堡"; +"enum.browsing.country.name.macau" = "澳门"; +"enum.browsing.country.name.macedonia" = "马其顿"; +"enum.browsing.country.name.madagascar" = "马达加斯加"; +"enum.browsing.country.name.malawi" = "马拉维"; +"enum.browsing.country.name.malaysia" = "马来西亚"; +"enum.browsing.country.name.maldives" = "马尔代夫"; +"enum.browsing.country.name.mali" = "马里"; +"enum.browsing.country.name.malta" = "马耳他"; +"enum.browsing.country.name.marshallIslands" = "马绍尔群岛"; +"enum.browsing.country.name.martinique" = "马提尼克"; +"enum.browsing.country.name.mauritania" = "毛里塔尼亚"; +"enum.browsing.country.name.mauritius" = "模里西斯"; +"enum.browsing.country.name.mayotte" = "马约特"; +"enum.browsing.country.name.mexico" = "墨西哥"; +"enum.browsing.country.name.micronesia" = "密克罗尼西亚"; +"enum.browsing.country.name.moldova" = "摩尔多瓦"; +"enum.browsing.country.name.monaco" = "摩纳哥"; +"enum.browsing.country.name.mongolia" = "蒙古"; +"enum.browsing.country.name.montenegro" = "黑山"; +"enum.browsing.country.name.montserrat" = "蒙塞拉特岛"; +"enum.browsing.country.name.morocco" = "摩洛哥"; +"enum.browsing.country.name.mozambique" = "莫桑比克"; +"enum.browsing.country.name.myanmar" = "缅甸"; +"enum.browsing.country.name.namibia" = "纳米比亚"; +"enum.browsing.country.name.nauru" = "诺鲁"; +"enum.browsing.country.name.nepal" = "尼泊尔"; +"enum.browsing.country.name.netherlands" = "荷兰"; +"enum.browsing.country.name.newCaledonia" = "新喀里多尼亚"; +"enum.browsing.country.name.newZealand" = "新西兰"; +"enum.browsing.country.name.nicaragua" = "尼加拉瓜"; +"enum.browsing.country.name.niger" = "尼日尔"; +"enum.browsing.country.name.nigeria" = "尼日利亚"; +"enum.browsing.country.name.niue" = "纽埃"; +"enum.browsing.country.name.norfolkIsland" = "诺福克岛"; +"enum.browsing.country.name.northKorea" = "朝鲜"; +"enum.browsing.country.name.northernMarianaIslands" = "北马里亚纳群岛"; +"enum.browsing.country.name.norway" = "挪威"; +"enum.browsing.country.name.oman" = "阿曼"; +"enum.browsing.country.name.pakistan" = "巴基斯坦"; +"enum.browsing.country.name.palau" = "帛琉"; +"enum.browsing.country.name.palestinianTerritory" = "巴勒斯坦"; +"enum.browsing.country.name.panama" = "巴拿马"; +"enum.browsing.country.name.papuaNewGuinea" = "巴布亚新几内亚"; +"enum.browsing.country.name.paraguay" = "巴拉圭"; +"enum.browsing.country.name.peru" = "秘鲁"; +"enum.browsing.country.name.philippines" = "菲律宾"; +"enum.browsing.country.name.pitcairnIslands" = "皮特凯恩群岛"; +"enum.browsing.country.name.poland" = "波兰"; +"enum.browsing.country.name.portugal" = "葡萄牙"; +"enum.browsing.country.name.puertoRico" = "波多黎各"; +"enum.browsing.country.name.qatar" = "卡塔尔"; +"enum.browsing.country.name.reunion" = "留尼汪"; +"enum.browsing.country.name.romania" = ""; +"enum.browsing.country.name.russianFederation" = "俄罗斯"; +"enum.browsing.country.name.rwanda" = "卢旺达"; +"enum.browsing.country.name.saintBarthelemy" = "圣巴泰勒米"; +"enum.browsing.country.name.saintHelena" = "圣赫勒拿"; +"enum.browsing.country.name.saintKittsAndNevis" = "圣基茨岛"; +"enum.browsing.country.name.saintLucia" = "圣卢西亚"; +"enum.browsing.country.name.saintMartin" = "圣马丁岛"; +"enum.browsing.country.name.saintPierreAndMiquelon" = "圣皮埃尔和密克隆"; +"enum.browsing.country.name.saintVincentAndTheGrenadines" = "圣文森特和格林纳丁斯"; +"enum.browsing.country.name.samoa" = "萨摩亚"; +"enum.browsing.country.name.sanMarino" = "圣马力诺"; +"enum.browsing.country.name.saoTomeAndPrincipe" = "圣多美和普林西比"; +"enum.browsing.country.name.saudiArabia" = "沙地阿拉伯"; +"enum.browsing.country.name.senegal" = "塞内加尔"; +"enum.browsing.country.name.serbia" = "塞尔维亚"; +"enum.browsing.country.name.seychelles" = "塞舌尔"; +"enum.browsing.country.name.sierraLeone" = "塞拉利昂"; +"enum.browsing.country.name.singapore" = "新加坡"; +"enum.browsing.country.name.sintMaarten" = "圣马丁岛"; +"enum.browsing.country.name.slovakia" = "斯洛伐克"; +"enum.browsing.country.name.slovenia" = "斯洛文尼亚"; +"enum.browsing.country.name.solomonIslands" = "所罗门群岛"; +"enum.browsing.country.name.somalia" = "索马里"; +"enum.browsing.country.name.southAfrica" = "南非"; +"enum.browsing.country.name.southGeorgiaAndTheSouthSandwichIslands" = "南乔治亚和南桑威奇群岛"; +"enum.browsing.country.name.southKorea" = "韩国"; +"enum.browsing.country.name.southSudan" = "南苏丹"; +"enum.browsing.country.name.spain" = "西班牙"; +"enum.browsing.country.name.sriLanka" = "斯里兰卡"; +"enum.browsing.country.name.sudan" = "苏丹"; +"enum.browsing.country.name.suriname" = "苏里南"; +"enum.browsing.country.name.svalbardAndJanMayen" = "斯瓦尔巴和扬马延"; +"enum.browsing.country.name.swaziland" = "史瓦帝尼"; +"enum.browsing.country.name.sweden" = "瑞典"; +"enum.browsing.country.name.switzerland" = "瑞士"; +"enum.browsing.country.name.syrianArabRepublic" = "叙利亚"; +"enum.browsing.country.name.taiwan" = "台湾"; +"enum.browsing.country.name.tajikistan" = "塔吉克斯坦"; +"enum.browsing.country.name.tanzania" = "坦桑尼亚"; +"enum.browsing.country.name.thailand" = "泰国"; +"enum.browsing.country.name.timorLeste" = "东帝汶"; +"enum.browsing.country.name.togo" = "多哥"; +"enum.browsing.country.name.tokelau" = "托克劳"; +"enum.browsing.country.name.tonga" = "汤加"; +"enum.browsing.country.name.trinidadAndTobago" = "特立尼达和多巴哥"; +"enum.browsing.country.name.tunisia" = "突尼斯"; +"enum.browsing.country.name.turkey" = "土耳其"; +"enum.browsing.country.name.turkmenistan" = "土库曼斯坦"; +"enum.browsing.country.name.turksAndCaicosIslands" = "特克斯和凯科斯群岛"; +"enum.browsing.country.name.tuvalu" = "图瓦卢"; +"enum.browsing.country.name.uganda" = "乌干达"; +"enum.browsing.country.name.ukraine" = "乌克兰"; +"enum.browsing.country.name.unitedArabEmirates" = "阿拉伯联合酋长国"; +"enum.browsing.country.name.unitedKingdom" = "英国"; +"enum.browsing.country.name.unitedStates" = "美国"; +"enum.browsing.country.name.unitedStatesMinorOutlyingIslands" = "美国本土外小岛屿"; +"enum.browsing.country.name.uruguay" = "乌拉圭"; +"enum.browsing.country.name.uzbekistan" = "乌兹别克斯坦"; +"enum.browsing.country.name.vanuatu" = "瓦努阿图"; +"enum.browsing.country.name.venezuela" = "委內瑞拉"; +"enum.browsing.country.name.vietnam" = "越南"; +"enum.browsing.country.name.virginIslandsBritish" = "英属维尔京群岛"; +"enum.browsing.country.name.virginIslandsUS" = "美属维尔京群岛"; +"enum.browsing.country.name.wallisAndFutuna" = "瓦利斯和富图纳"; +"enum.browsing.country.name.westernSahara" = "西撒哈拉"; +"enum.browsing.country.name.yemen" = "也门"; +"enum.browsing.country.name.zambia" = "赞比亚"; +"enum.browsing.country.name.zimbabwe" = "津巴布韦"; diff --git a/EhPanda/App/zh-Hant.lproj/InfoPlist.strings b/EhPanda/App/zh-Hant.lproj/InfoPlist.strings index 932ef993..aa839fdc 100644 --- a/EhPanda/App/zh-Hant.lproj/InfoPlist.strings +++ b/EhPanda/App/zh-Hant.lproj/InfoPlist.strings @@ -7,4 +7,4 @@ */ "NSFaceIDUsageDescription" = "需要此權限以在解鎖App時提供Face ID選項"; -//"NSPhotoLibraryAddUsageDescription" = ""; +"NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index facb5929..a57806bd 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -1,474 +1,878 @@ -/* +/* Localizable.strings EhPanda Created by 荒木辰造 on R 2/12/25. - + */ +// MARK: BanInterval +"enum.ban.interval.description.and" = "and"; + +// MARK: ToplistsType +"enum.toplists.type.value.yesterday" = "Yesterday"; +"enum.toplists.type.value.pastMonth" = "Past month"; +"enum.toplists.type.value.pastYear" = "Past year"; +"enum.toplists.type.value.allTime" = "All time"; + // MARK: Response -"You must have a H@H client assigned to your account to use this feature." = "你需要一個關聯到帳號的 Hath 客戶端才能使用這個功能"; -"Your H@H client appears to be offline. Turn it on, then try again." = "你的 Hath 客戶端為離線狀態,請啓動後再試"; -"The requested gallery cannot be downloaded with the selected resolution." = "該畫廊不能以選定的解析度下載"; +"hath.download.response.hathClientNotFound" = "你需要一個關聯到帳號的 H@H 客戶端才能使用這個功能"; +"hath.download.response.hathClientNotOnline" = "你的 H@H 客戶端為離線狀態,請啓動後再試"; +"hath.download.response.invalidResolution" = "該畫廊不能以選定的解析度下載"; // MARK: HUD -"Success" = "成功"; -"Error" = "錯誤"; -"Communicating..." = "與伺服器通訊中..."; -"Copied to clipboard" = "已複製到剪貼簿"; +"hud.title.error" = "錯誤"; +"hud.title.success" = "成功"; +"hud.title.loading" = "載入中..."; +"hud.title.communicating" = "通訊中..."; +"hud.caption.copiedToClipboard" = "已複製到剪貼簿"; +"hud.caption.savedToPhotoLibrary" = "Saved to photo library"; + +// MARK: AutoLock +"local.authorization.reason" = "因超過自動鎖定期限,App 已被鎖定"; + +// MARK: Common value +"common.value.stars" = "%@ 星"; +"common.value.pages" = "%@ 頁"; +"common.value.times" = "%@ 次"; +"common.value.day" = "%@ day"; +"common.value.days" = "%@ days"; +"common.value.hour" = "%@ hour"; +"common.value.hours" = "%@ hours"; +"common.value.minute" = "%@ minute"; +"common.value.minutes" = "%@ minutes"; +"common.value.second" = "%@ second"; +"common.value.seconds" = "%@ seconds"; +"common.value.records" = "%@ 條記錄"; + +// MARK: TabItem +"tab.item.title.home" = "主頁"; +"tab.item.title.favorites" = "收藏"; +"tab.item.title.search" = "搜尋"; +"tab.item.title.setting" = "設定"; + +// MARK: ToolbarItem +"toolbar.item.button.filters" = "篩選"; +"toolbar.item.button.jumpPage" = "Jump page"; +"toolbar.item.button.quickSearch" = "快速搜尋"; + +// MARK: JumpPage +"jump.page.view.title.jumpPage" = "Jump page"; +"jump.page.view.button.confirm" = "Confirm"; -// MARK: LockView -"The App has been locked due to the auto-lock expiration." = "因超過自動鎖定期限,App 已被鎖定"; +// MARK: AlertView +"loading.view.title.loading" = "載入中..."; +"loading.view.title.preparingDatabase" = "Preparing the database..."; +"not.login.view.title.needLogin" = "You need to login to access this feature."; +"not.login.view.button.login" = "Login"; +"error.view.button.retry" = "重試"; +"error.view.button.dropDatabase" = "Drop the database"; +"error.view.title.tryLater" = "請稍後再試"; +"error.view.title.network" = "網絡發生故障"; +"error.view.title.parsing" = "A parsing error occurred."; +"error.view.title.unknown" = "An unknown error occurred."; +"error.view.title.notFound" = "There seems to be nothing here."; +"error.view.title.databaseCorrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; +"error.view.title.ipBanned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; +"error.view.title.copyrightClaim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; +"error.view.title.galleryUnavailable" = "This gallery has been removed or is unavailable."; + +// MARK: ConfirmationDialog +"confirmation.dialog.title.dropDatabase" = "You will lose all your data in this app.\nAre you sure to drop the database?"; +"confirmation.dialog.title.removeCustomTranslations" = "Are you sure to remove your custom translations?"; +"confirmation.dialog.title.logout" = "確定要登出嗎?"; +"confirmation.dialog.title.delete" = "Are you sure to delete this item?"; +"confirmation.dialog.title.clear" = "確定要清空嗎?"; +"confirmation.dialog.title.reset" = "確定要重置嗎?"; +"confirmation.dialog.button.dropDatabase" = "Drop the database"; +"confirmation.dialog.button.remove" = "Remove"; +"confirmation.dialog.button.logout" = "登出"; +"confirmation.dialog.button.delete" = "Delete"; +"confirmation.dialog.button.clear" = "清空"; +"confirmation.dialog.button.reset" = "重置"; + +// MARK: SubSection +"sub.section.button.showAll" = "顯示全部"; -// MARK: Common -"null" = "無內容"; -"expired" = "已過期"; -"mystery" = "被拒絕"; +// MARK: NewDawnView +"new.dawn.view.title.first" = "現在是嶄新的一天!"; +"new.dawn.view.title.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; +// Greeting +"struct.greeting.mark.start" = "你獲得了 "; +"struct.greeting.mark.separator" = "、"; +"struct.greeting.mark.and" = " 和 "; +"struct.greeting.mark.end" = "!"; -// MARK: User -"favoriteNameByDev" = "收藏夾"; -"all_appendedByDev" = "全部"; +// MARK: HomeView +"home.view.title.home" = "主頁"; +"home.view.section.title.frontpage" = "Frontpage"; +"home.view.section.title.toplists" = "Toplists"; +"home.view.section.title.other" = "其它"; +// HomeMiscGridType +"home.misc.grid.type.title.popular" = "熱門"; +"home.misc.grid.type.title.watched" = "標籤"; +"home.misc.grid.type.title.history" = "歷史"; + +// MARK: FrontpageView +"frontpage.view.title.frontpage" = "Frontpage"; + +// MARK: ToplistsView +"toplists.view.title.toplists" = "Toplists"; + +// MARK: PopularView +"popular.view.title.popular" = "熱門"; + +// MARK: WatchedView +"watched.view.title.watched" = "標籤"; + +// MARK: HistoryView +"history.view.title.history" = "歷史"; + +// MARK: FavoritesView +"favorites.view.title.favorites" = "收藏"; +// FavoriteCategory +"favorite.category.default" = "收藏夾 %@"; +"favorite.category.all" = "全部"; + +// MARK: SearchView +"search.view.title.search" = "搜尋"; +"search.view.section.title.recentlySearched" = "Recently searched"; +"search.view.section.title.recentlySeen" = "Recently seen"; +"search.view.section.title.quickSearch" = "快速搜尋"; +// Searchable prompt +"searchable.prompt.filter" = "篩選"; -// MARK: AlertView -"Loading..." = "載入中..."; -"Login" = "登入"; -"There seems to be nothing here." = "這裡似乎什麼也沒有"; -"Retry" = "重試"; -"A network error occurred." = "網絡發生故障"; -//"A parsing error occurred." = ""; -//"An unknown error occurred." = ""; -"Please try again later." = "請稍後再試"; -//"This gallery has been removed or is unavailable." = ""; -//"This gallery is unavailable due to a copyright claim by PLACEHOLDER. Sorry about that." = ""; -//"Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software." = ""; -//"The ban expires in PLACEHOLDER." = ""; -"BAN_INTERVAL_AND" = " and "; -"BAN_INTERVAL_DAYS" = " days"; -"BAN_INTERVAL_HOURS" = " hours"; -"BAN_INTERVAL_MINUTES" = " minutes"; -"BAN_INTERVAL_SECONDS" = " seconds"; -//"Jump page" = ""; -//"Confirm" = ""; +// MARK: QuickSearchView +"quick.search.view.title.quickSearch" = "快速搜尋"; +"quick.search.view.title.editWord" = "Edit word"; +"quick.search.view.title.newWord" = "New word"; +"quick.search.view.title.content" = "Content"; +"quick.search.view.title.name" = "名稱"; +"quick.search.view.placeholder.optional" = "Optional"; +"quick.search.view.toolbar.item.button.confirm" = "Confirm"; -// MARK: HomeView -"Clear history" = "清空歷史"; +// MARK: SettingView +"setting.view.title.setting" = "設定"; +// SettingStateRoute +"enum.setting.state.route.value.account" = "帳號"; +"enum.setting.state.route.value.general" = "一般"; +"enum.setting.state.route.value.appearance" = "外觀"; +"enum.setting.state.route.value.reading" = "閱讀"; +"enum.setting.state.route.value.laboratory" = "實驗室"; +"enum.setting.state.route.value.ehPanda" = "關於 EhPanda"; + +// MARK: AccountSettingView +"account.setting.view.title.account" = "帳號"; +"account.setting.view.title.showsNewDawnGreeting" = "顯示黎明問候"; +"account.setting.view.button.login" = "登入"; +"account.setting.view.button.logout" = "登出"; +"account.setting.view.button.accountConfiguration" = "帳號設定"; +"account.setting.view.button.tagsManagement" = "管理訂閱標籤"; +"account.setting.view.button.copyCookies" = "複製 cookies"; +// CookieValue +"struct.cookie.value.localized.string.expired" = "已過期"; +"struct.cookie.value.localized.string.mystery" = "被拒絕"; +"struct.cookie.value.localized.string.none" = "無內容"; + +// MARK: LoginView +"login.view.title.login" = "登入"; +"login.view.title.username" = "Username"; +"login.view.title.password" = "Password"; + +// MARK: GeneralSettingView +"general.setting.view.title.general" = "一般"; +"general.setting.view.title.language" = "語言"; +"general.setting.view.title.autoLock" = "自動鎖定"; +"general.setting.view.title.translatesTags" = "翻譯標籤"; +"general.setting.view.title.redirectsLinksToTheSelectedHost" = "重定向連結到選定的站點"; +"general.setting.view.title.detectsLinksFromClipboard" = "偵測剪貼簿中的連結"; +"general.setting.view.title.backgroundBlurRadius" = "Background blur radius"; +"general.setting.view.button.logs" = "日誌"; +"general.setting.view.button.importCustomTranslations" = "Import custom translations"; +"general.setting.view.button.removeCustomTranslations" = "Remove custom translations"; +"general.setting.view.button.clearImageCaches" = "清空圖片快取"; +"general.setting.view.value.defaultLanguageDescription" = "N/A"; +"general.setting.view.section.title.tagsTranslation" = "Tags translation"; +"general.setting.view.section.title.navigation" = "導航"; +"general.setting.view.section.title.security" = "安全"; +"general.setting.view.section.title.caches" = "快取"; +// AutoLockPolicy +"enum.auto.lock.policy.value.never" = "不鎖定"; +"enum.auto.lock.policy.value.instantly" = "立即"; + +// MARK: LogsView +"logs.view.title.logs" = "日誌"; +"logs.view.title.latest" = "最新"; + +// MARK: AppearanceSettingView +"appearance.setting.view.title.appearance" = "外觀"; +"appearance.setting.view.title.theme" = "主題"; +"appearance.setting.view.title.tintColor" = "主題色"; +"appearance.setting.view.title.displayMode" = "顯示模式"; +"appearance.setting.view.title.showsTagsInList" = "在列表中顯示標籤"; +"appearance.setting.view.title.maximumNumberOfTags" = "標籤數量上限"; +"appearance.setting.view.button.appIcon" = "應用圖標"; +"appearance.setting.view.menu.title.infite" = "無限"; +"appearance.setting.view.section.title.list" = "列表"; +// PerferredColorScheme +"enum.perferred.color.scheme.value.automatic" = "自動"; +"enum.perferred.color.scheme.value.light" = "淺色"; +"enum.perferred.color.scheme.value.dark" = "深色"; +// AppIconType +"enum.app.icon.type.value.default" = "預設"; +"enum.app.icon.type.value.ukiyoe" = "Ukiyo-e"; +// ListDisplayMode +"enum.display.mode.value.detail" = "詳情"; +"enum.display.mode.value.thumbnail" = "縮略圖"; + +// MARK: AppIconView +"app.icon.view.title.appIcon" = "應用圖標"; + +// MARK: ReadingSettingView +"reading.setting.view.title.reading" = "閱讀"; +"reading.setting.view.title.direction" = "方向"; +"reading.setting.view.title.preloadLimit" = "預先載入數量上限"; +"reading.setting.view.title.enablesLandscape" = "Enables landscape"; +"reading.setting.view.title.separatorHeight" = "分隔線高度"; +"reading.setting.view.title.maximumScaleFactor" = "縮放上限"; +"reading.setting.view.title.doubleTapScaleFactor" = "雙擊縮放值"; +"reading.setting.view.section.title.appearance" = "外觀"; +// ReadingDirection +"enum.reading.direction.value.vertical" = "垂直"; +"enum.reading.direction.value.rightToLeft" = "右至左"; +"enum.reading.direction.value.leftToRight" = "左至右"; + +// MARK: LaboratorySettingView +"laboratory.setting.view.title.laboratory" = "實驗室"; +"laboratory.setting.view.title.bypassesSNIFiltering" = "域前置繞過 SNI 阻斷"; + +// MARK: EhPandaView +"ehpanda.view.title.ehPanda" = "EhPanda"; +"ehpanda.view.button.website" = "網站"; +"ehpanda.view.button.altStoreSource" = "AltStore 源"; +"ehpanda.view.description.version" = "版本"; +"ehpanda.view.section.title.specialThanks" = "Special thanks"; +"ehpanda.view.section.title.codeLevelContributors" = "Code-level contributors"; +"ehpanda.view.section.title.translationContributors" = "Translation contributors"; +"ehpanda.view.section.title.acknowledgements" = "致謝"; // MARK: DetailView -"Archive" = "封存"; -"Torrents" = "種子"; -"Share" = "分享"; -"Read" = "閱讀"; -"DESC_SCROLL_ITEM_FAVORITED" = "收藏"; -"Times" = "次"; -"Language" = "語言"; -"%lld Ratings" = "%lld 個評分"; -"Page Count" = "頁數"; -"Pages" = "頁"; -"File Size" = "文件大小"; -"Give a Rating" = "給予評分"; -"Similar Gallery" = "相似畫廊"; -"Preview" = "預覽"; -"Comment" = "評論"; -"Show All" = "顯示全部"; - -// MARK: ArchiveView -"N/A" = "不適用"; -"Free" = "免費"; -"ARCHIVE_RESOLUTION_ORIGINAL" = "原始解析度"; -"Download To Hath Client" = "下載到 Hath 客戶端"; +"detail.view.button.read" = "閱讀"; +"detail.view.button.postComment" = "發表評論"; +"detail.view.toolbar.item.button.archives" = "封存"; +"detail.view.toolbar.item.button.torrents" = "種子"; +"detail.view.toolbar.item.button.share" = "分享"; +"detail.view.scroll.section.title.favorited" = "收藏"; +"detail.view.scroll.section.title.language" = "語言"; +"detail.view.scroll.section.title.ratings" = "%@ 個評分"; +"detail.view.scroll.section.title.pageCount" = "頁數"; +"detail.view.scroll.section.title.fileSize" = "文件大小"; +"detail.view.scroll.section.description.favorited" = "次"; +"detail.view.scroll.section.description.pageCount" = "頁"; +"detail.view.action.section.button.giveARating" = "給予評分"; +"detail.view.action.section.button.similarGallery" = "相似畫廊"; +"detail.view.section.title.previews" = "預覽"; +"detail.view.section.title.comments" = "評論"; + +// MARK: ArchivesView +"archives.view.title.archives" = "封存"; +"archives.view.button.downloadToHathClient" = "下載到 H@H 客戶端"; +// HathArchive +"struct.hath.archive.price.value.free" = "免費"; +"struct.hath.archive.price.value.notAvailable" = "不適用"; +"struct.hath.archive.resolution.value.original" = "原始解析度"; + +// MARK: TorrentsView +"torrents.view.title.torrents" = "種子"; // MARK: GalleryInfosView -//"Gallery infos" = ""; -//"Title" = ""; -//"Japanese title" = ""; -//"Gallery URL" = ""; -//"Cover URL" = ""; -//"Archive URL" = ""; -//"Torrent URL" = ""; -//"Parent URL" = ""; -//"Category" = ""; -//"Uploader" = ""; -//"Posted date" = ""; -//"Visible" = ""; -//"Page count" = ""; -//"File size" = ""; -//"Favorited times" = ""; -//"Favorited" = ""; -//"Rating count" = ""; -//"Average rating" = ""; -//"User rating" = ""; -//"Torrent count" = ""; -//"Yes" = ""; -//"No" = ""; -//"Expunged" = ""; - -// MARK: CommentView -"Post Comment" = "發表評論"; -"Edit Comment" = "編輯評論"; -"Cancel" = "取消"; -"Post" = "發表"; +"gallery.infos.view.title.galleryInfos" = "Gallery infos"; +"gallery.infos.view.title.ID" = "ID"; +"gallery.infos.view.title.token" = "Token"; +"gallery.infos.view.title.title" = "Title"; +"gallery.infos.view.title.japaneseTitle" = "Japanese title"; +"gallery.infos.view.title.galleryURL" = "Gallery URL"; +"gallery.infos.view.title.coverURL" = "Cover URL"; +"gallery.infos.view.title.archiveURL" = "Archive URL"; +"gallery.infos.view.title.torrentURL" = "Torrent URL"; +"gallery.infos.view.title.parentURL" = "Parent URL"; +"gallery.infos.view.title.category" = "Category"; +"gallery.infos.view.title.uploader" = "Uploader"; +"gallery.infos.view.title.postedDate" = "Posted date"; +"gallery.infos.view.title.visibility" = "Visibility"; +"gallery.infos.view.title.language" = "Language"; +"gallery.infos.view.title.pageCount" = "Page count"; +"gallery.infos.view.title.fileSize" = "File size"; +"gallery.infos.view.title.favoritedTimes" = "Favorited times"; +"gallery.infos.view.title.favorited" = "Favorited"; +"gallery.infos.view.title.ratingCount" = "Rating count"; +"gallery.infos.view.title.averageRating" = "Average rating"; +"gallery.infos.view.title.myRating" = "My rating"; +"gallery.infos.view.title.torrentCount" = "Torrent count"; +"gallery.infos.view.value.none" = "None"; +"gallery.infos.view.value.yes" = "Yes"; +"gallery.infos.view.value.no" = "No"; +// GalleryVisibility +"gallery.visibility.value.yes" = "Yes"; +"gallery.visibility.value.no" = "No (%@)"; +"gallery.visibility.value.no.reason.expunged" = "Expunged"; + +// MARK: CommentsView +"comments.view.title.comments" = "評論"; + +// MARK: PostCommentView +"post.comment.view.title.postComment" = "發表評論"; +"post.comment.view.title.editComment" = "編輯評論"; +"post.comment.view.button.cancel" = "取消"; +"post.comment.view.button.post" = "發表"; + +// MARK: PreviewsView +"previews.view.title.previews" = "預覽"; // MARK: ReadingView -//"AutoPlay" = ""; -//"Reload" = ""; -//"Copy" = ""; -//"Save" = ""; -//"Save original" = ""; -//"Saved to photo library" = ""; - -// MARK: SettingView -"Setting" = "設定"; -"Account" = "帳號"; -"Gallery" = "畫廊"; -"Login" = "登入"; -//"Username" = ""; -//"Password" = ""; -"Logout" = "登出"; -"Are you sure to logout?" = "確定要登出嗎?"; -"Account configuration" = "帳號設定"; -"Manage tags subscription" = "管理訂閱標籤"; -"Copy cookies" = "複製 Cookies"; - -"General" = "一般"; -"Navigation" = "導航"; -"Redirects links to the selected host" = "重定向連結到選定的站點"; -"Detects links from the clipboard" = "偵測剪貼簿中的連結"; -"Security" = "安全"; -"Auto-Lock" = "自動鎖定"; -"App switcher blur" = "模糊多工處理中的預覽"; -"Cache" = "快取"; -"Clear" = "清空"; -"Are you sure to clear?" = "確定要清空嗎?"; -"Clear image caches" = "清空圖片快取"; - -"Appearance" = "外觀"; -"Global" = "全局"; -"Theme" = "主題"; -"Tint Color" = "主題色"; -"App Icon" = "應用圖標"; -"Translates tags" = "翻譯標籤"; -"List" = "列表"; -"Display mode" = "顯示模式"; -"LIST_DISPLAY_MODE_DETAIL" = "詳情"; -"LIST_DISPLAY_MODE_THUMBNAIL" = "縮略圖"; -"Shows tags in list" = "在列表中顯示標籤"; -"Maximum number of tags" = "標籤數量上限"; -"Infinity" = "無限"; - -"Reading" = "閱讀"; -"Direction" = "方向"; -"READING_DIRECTION_VERTICAL" = "垂直"; -"Right-to-left" = "右至左"; -"Left-to-right" = "左至右"; -"Preload limit" = "預先載入數量上限"; -"%lld pages" = "%lld 頁"; -"%lld times" = "%lld 次"; -//"Prefers landscape" = ""; -"Separator height" = "分隔線高度"; -"Maximum scale factor" = "縮放上限"; -"Double tap scale factor" = "雙擊縮放值"; -"Dual-page mode" = "雙頁模式"; -"Except the cover" = "封面除外"; - -"Laboratory" = "實驗室"; -"Bypass SNI Filtering" = "域前置繞過 SNI 阻斷"; - -"About EhPanda" = "關於 EhPanda"; -"Version" = "版本"; -"Website" = "網站"; -"AltStore Source" = "AltStore 源"; -"Acknowledgement" = "致謝"; +"reading.view.context.menu.button.reload" = "Reload"; +"reading.view.context.menu.button.copy" = "Copy"; +"reading.view.context.menu.button.save" = "Save"; +"reading.view.context.menu.button.saveOriginal" = "Save original"; +"reading.view.context.menu.button.share" = "Share"; +"reading.view.toolbar.item.title.autoPlay" = "Auto-Play"; +"reading.view.toolbar.item.title.dualPageMode" = "雙頁模式"; +"reading.view.toolbar.item.title.exceptTheCover" = "封面除外"; +// AutoPlayPolicy +"enum.auto.play.policy.value.off" = "Off"; + +// MARK: FiltersView +"filters.view.title.filters" = "篩選"; +"filters.view.title.advancedSettings" = "進階選項"; +"filters.view.title.searchGalleryName" = "搜尋畫廊名稱"; +"filters.view.title.searchGalleryTags" = "搜尋畫廊標籤"; +"filters.view.title.searchGalleryDescription" = "搜尋畫廊描述"; +"filters.view.title.searchTorrentFilenames" = "搜尋種子檔案名"; +"filters.view.title.onlyShowGalleriesWithTorrents" = "只顯示有種子的畫廊"; +"filters.view.title.searchLowPowerTags" = "搜尋低期望標籤"; +"filters.view.title.searchDownvotedTags" = "搜尋低評價標籤"; +"filters.view.title.showExpungedGalleries" = "顯示被刪除的畫廊"; +"filters.view.title.setMinimumRating" = "設定評分下限"; +"filters.view.title.minimumRating" = "評分下限"; +"filters.view.title.setPagesRange" = "設定頁數範圍"; +"filters.view.title.pagesRange" = "頁數範圍"; +"filters.view.title.disableLanguageFilter" = "禁用語言篩選"; +"filters.view.title.disableUploaderFilter" = "禁用上傳者篩選"; +"filters.view.title.disableTagsFilter" = "禁用標籤篩選"; +"filters.view.button.resetFilters" = "重置所有選項"; +"filters.view.section.title.advanced" = "進階"; +"filters.view.section.title.defaultFilter" = "預設篩選"; +// FilterRange +"enum.filter.range.value.search" = "搜尋"; +"enum.filter.range.value.global" = "全局"; +"enum.filter.range.value.watched" = "標籤"; // MARK: EhSettingView -//"Profile Settings" = ""; -//"Selected profile" = ""; -//"Set as default" = ""; -//"Delete profile" = ""; -//"Are you sure to delete this profile?" = ""; -//"Delete" = ""; -//"Rename" = ""; -//"Create new" = ""; -// -//"Image Load Settings" = ""; -//"Recommended." = ""; -//"Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports." = ""; -//"Donator only. You will not be able to browse as many pages, enable only if having severe problems." = ""; -//"Load images through the Hath network" = ""; -//"Any client" = ""; -//"Default port clients only" = ""; -//"LOAD_THROUGH_HATH_NO" = ""; -//"You appear to be browsing the site from **PLACEHOLDER** or use a VPN or proxy in this country, which means the site will try to load images from Hath clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below." = ""; -//"Browsing country" = ""; -// -//"Image Size Settings" = ""; -//"Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000." = ""; -//"Image resolution" = ""; -//"Auto" = ""; -//"While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)" = ""; -//"Image size" = ""; -//"Horizontal" = ""; -//"Vertical" = ""; -// -//"Gallery Name Display" = ""; -//"Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?" = ""; -//"Gallery name" = ""; -//"Default Title" = ""; -//"Japanese Title (if available)" = ""; -// -//"Archiver Settings" = ""; -//"The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here." = ""; -//"Archiver behavior" = ""; -//"Manual Select, Manual Start (Default)" = ""; -//"Manual Select, Auto Start" = ""; -//"Auto Select Original, Manual Start" = ""; -//"Auto Select Original, Auto Start" = ""; -//"Auto Select Resample, Manual Start" = ""; -//"Auto Select Resample, Auto Start" = ""; -// -//"Front Page Settings" = ""; -//"Which display mode would you like to use on the front and search pages?" = ""; -//"Compact" = ""; -//"Thumbnail" = ""; -//"Extended" = ""; -//"Minimal" = ""; -//"Minimal+" = ""; -//"What categories would you like to show by default on the front page and in searches?" = ""; -// -//"Here you can choose and rename your favorite categories." = ""; -//"You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting." = ""; -//"Favorites sort order" = ""; -//"By last gallery update time" = ""; -//"By favorited time" = ""; -// -//"Ratings" = ""; -//"By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works." = ""; -//"Ratings color" = ""; -// -//"Tag Namespaces" = ""; -//"If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces." = ""; -// -//"Tag Filtering Threshold" = ""; -//"You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999." = ""; -// -//"Tag Watching Threshold" = ""; -//"Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999." = ""; -// -//"Excluded Languages" = ""; -//"If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query." = ""; -//"Original" = ""; -//"Translated" = ""; -//"Rewrite" = ""; -// -//"Excluded Uploaders" = ""; -//"If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query." = ""; -//"You are currently using **%lld / 1000** exclusion slots." = ""; -// -//"Search Result Count" = ""; -//"How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)" = ""; -//"Result count" = ""; -// -//"Thumbnail Settings" = ""; -//"How would you like the mouse-over thumbnails on the front page to load when using List Mode?" = ""; -//"Pages load faster, but there may be a slight delay before a thumb appears." = ""; -//"Pages take longer to load, but there is no delay for loading a thumb after the page has loaded." = ""; -//"Thumbnail load timing" = ""; -//"On mouse-over" = ""; -//"On page load" = ""; -//"You can set a default thumbnail configuration for all galleries you visit." = ""; -//"Size" = ""; -//"Large" = ""; -//"Rows" = ""; -// -//"Thumbnail Scaling" = ""; -//"Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%." = ""; -//"Scale factor" = ""; -// -//"Viewport Override" = ""; -//"Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400." = ""; -//"Virtual width" = ""; -// -//"Gallery Comments" = ""; -//"Comments sort order" = ""; -//"Oldest comments first" = ""; -//"Recent comments first" = ""; -//"By highest score" = ""; -//"Comment votes show timing" = ""; -//"On score hover or click" = ""; -//"Always" = ""; -// -//"Gallery Tags" = ""; -//"Tags sort order" = ""; -//"Alphabetical" = ""; -//"By tag power" = ""; -// -//"Gallery Page Numbering" = ""; -//"Show gallery page numbers" = ""; -// -//"Hath Local Network Host" = ""; -//"This setting can be used if you have a Hath client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work." = ""; -//"IP address:Port" = ""; -// -//"Original Images" = ""; -//"Use original images" = ""; -//"Multi-Page Viewer" = ""; -//"Use Multi-Page Viewer" = ""; -//"Display style" = ""; -//"Align left, scale if overwidth" = ""; -//"Align center, scale if overwidth" = ""; -//"Align center, always scale" = ""; -//"Show thumbnail pane" = ""; - -// MARK: LogsView -"Logs" = "日誌"; -"Latest" = "最新"; -"%lld records" = "%lld 條紀錄"; - -// MARK: FilterView -"Filters" = "篩選"; -"Basic" = "基本"; -"Reset filters" = "重置所有選項"; -"Are you sure to reset?" = "確定要重置嗎?"; -"Reset" = "重置"; -"Advanced settings" = "進階選項"; -"Advanced" = "進階"; -"Search gallery name" = "搜尋畫廊名稱"; -"Search gallery tags" = "搜尋畫廊標籤"; -"Search gallery description" = "搜尋畫廊描述"; -"Search torrent filenames" = "搜尋種子檔案名"; -"Only show galleries with torrents" = "只顯示有種子的畫廊"; -"Search Low-Power tags" = "搜尋低期望標籤"; -"Search downvoted tags" = "搜尋低評價標籤"; -"Show expunged galleries" = "顯示被刪除的畫廊"; -"Set minimum rating" = "設定評分下限"; -"Minimum rating" = "評分下限"; -"%lld stars" = "%lld 星"; -"Set pages range" = "設定頁數範圍"; -"Pages range" = "頁數範圍"; -"Default Filter" = "預設篩選"; -"Disable language filter" = "禁用語言篩選"; -"Disable uploader filter" = "禁用上傳者篩選"; -"Disable tags filter" = "禁用標籤篩選"; - -// MARK: NewDawnView -"Show new dawn greeting" = "顯示黎明問候"; -"It is the dawn of a new day!" = "現在是嶄新的一天!"; -"Reflecting on your journey so far, you find that you are a little wiser." = "回顧到目前為止的旅程,您發現自己睿智了一點。"; -"GAINCONTENT_START" = "你獲得了"; -"GAINCONTENT_SEPARATOR" = "、"; -"GAINCONTENT_AND" = "和"; -"GAINCONTENT_END" = "!"; - -// MARK: QuickSearchView -"Quick search" = "快速搜尋"; -//"Alias" = ""; - -// MARK: HomeListType -"Search" = "搜尋"; -"Frontpage" = "主頁"; -"Popular" = "熱門"; -"Watched" = "標籤"; -"Favorites" = "收藏"; -//"Toplists" = ""; -"Downloaded" = "下載"; -"History" = "歷史"; - -// MARK: ToplistType -//"All time" = ""; -//"Past year" = ""; -//"Past month" = ""; -//"Yesterday" = ""; +"eh.setting.view.title.hostSetting" = "%@ setting"; +"eh.setting.view.section.title.profileSettings" = "Profile Settings"; +"eh.setting.view.title.selectedProfile" = "Selected profile"; +"eh.setting.view.button.setAsDefault" = "Set as default"; +"eh.setting.view.button.deleteProfile" = "Delete profile"; +"eh.setting.view.button.rename" = "Rename"; +"eh.setting.view.button.createNew" = "Create new"; +"eh.setting.view.toolbar.item.button.done" = "Done"; + +"eh.setting.view.section.title.imageLoadSettings" = "Image Load Settings"; +"eh.setting.view.title.loadImagesThroughTheHathNetwork" = "Load images through the Hath network"; +"eh.setting.view.title.browsingCountry" = "Browsing country"; +"eh.setting.view.description.browsingCountry" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; +// EhSetting.LoadThroughHathSetting +"enum.eh.setting.load.through.hath.setting.value.anyClient" = "Any client"; +"enum.eh.setting.load.through.hath.setting.value.defaultPortOnly" = "Default port clients only"; +"enum.eh.setting.load.through.hath.setting.value.modernNo" = "No [Modern/HTTPS]"; +"enum.eh.setting.load.through.hath.setting.value.legacyNo" = "No [Legacy/HTTP]"; +"enum.eh.setting.load.through.hath.setting.description.anyClient" = "Recommended."; +"enum.eh.setting.load.through.hath.setting.description.defaultPortOnly" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; +"enum.eh.setting.load.through.hath.setting.description.modernNo" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; +"enum.eh.setting.load.through.hath.setting.description.legacyNo" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; + +"eh.setting.view.section.title.imageSizeSettings" = "Image Size Settings"; +"eh.setting.view.title.imageResolution" = "Image resolution"; +"eh.setting.view.description.imageResolution" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; +"eh.setting.view.title.imageSize" = "Image size"; +"eh.setting.view.description.imageSize" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; +"eh.setting.view.title.horizontal" = "Horizontal"; +"eh.setting.view.title.vertical" = "Vertical"; +// EhSetting.ImageResolution +"enum.eh.setting.image.resolution.value.auto" = "Auto"; + +"eh.setting.view.section.title.galleryNameDisplay" = "Gallery Name Display"; +"eh.setting.view.title.galleryName" = "Gallery name"; +"eh.setting.view.description.galleryName" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; +// EhSetting.GalleryName +"enum.eh.setting.gallery.name.value.default" = "Default Title"; +"enum.eh.setting.gallery.name.value.japanese" = "Japanese Title (if available)"; + +"eh.setting.view.section.title.archiverSettings" = "Archiver Settings"; +"eh.setting.view.title.archiverBehavior" = "Archiver behavior"; +"eh.setting.view.description.archiverBehavior" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; +// EhSetting.ArchiverBehavior +"enum.eh.setting.archiver.behavior.value.manualSelectManualStart" = "Manual Select, Manual Start (Default)"; +"enum.eh.setting.archiver.behavior.value.manualSelectAutoStart" = "Manual Select, Auto Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalManualStart" = "Auto Select Original, Manual Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectOriginalAutoStart" = "Auto Select Original, Auto Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleManualStart" = "Auto Select Resample, Manual Start"; +"enum.eh.setting.archiver.behavior.value.autoSelectResampleAutoStart" = "Auto Select Resample, Auto Start"; + +"eh.setting.view.section.title.frontPageSettings" = "Front Page Settings"; +"eh.setting.view.title.displayMode" = "Display mode"; +"eh.setting.view.description.displayMode" = "Which display mode would you like to use on the front and search pages?"; +"eh.setting.view.description.galleryCategory" = "What categories would you like to show by default on the front page and in searches?"; +// EhSetting.DisplayMode +"enum.eh.setting.display.mode.value.compact" = "Compact"; +"enum.eh.setting.display.mode.value.thumbnail" = "Thumbnail"; +"enum.eh.setting.display.mode.value.extended" = "Extended"; +"enum.eh.setting.display.mode.value.minimal" = "Minimal"; +"enum.eh.setting.display.mode.value.minimalPlus" = "Minimal+"; + +"eh.setting.view.section.title.favorites" = "Favorites"; +"eh.setting.view.description.favoriteCategories" = "Here you can choose and rename your favorite categories."; +"eh.setting.view.title.favoritesSortOrder" = "Favorites sort order"; +"eh.setting.view.description.favoritesSortOrder" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; +// EhSetting.FavoritesSortOrder +"enum.eh.setting.favorites.sort.order.value.lastUpdateTime" = "By last gallery update time"; +"enum.eh.setting.favorites.sort.order.value.favoritedTime" = "By favorited time"; + +"eh.setting.view.section.title.ratings" = "Ratings"; +"eh.setting.view.title.ratingsColor" = "Ratings color"; +"eh.setting.view.promt.ratingsColor" = "RRGGB"; +"eh.setting.view.description.ratingsColor" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; + +"eh.setting.view.section.title.tagsNamespaces" = "Tag Namespaces"; +"eh.setting.view.description.tagsNamespaces" = "If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces."; + +"eh.setting.view.section.title.tagFilteringThreshold" = "Tag Filtering Threshold"; +"eh.setting.view.title.tagFilteringThreshold" = "Tag Filtering Threshold"; +"eh.setting.view.description.tagFilteringThreshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; + +"eh.setting.view.section.title.tagWatchingThreshold" = "Tag Watching Threshold"; +"eh.setting.view.title.tagWatchingThreshold" = "Tag Watching Threshold"; +"eh.setting.view.description.tagWatchingThreshold" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; + +"eh.setting.view.section.title.excludedLanguages" = "Excluded Languages"; +"eh.setting.view.description.excludedLanguages" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; +// EhSetting.ExcludedLanguagesCategory +"enum.eh.setting.excluded.languages.category.value.original" = "Original"; +"enum.eh.setting.excluded.languages.category.value.translated" = "Translated"; +"enum.eh.setting.excluded.languages.category.value.rewrite" = "Rewrite"; + +"eh.setting.view.section.title.excludedUploaders" = "Excluded Uploaders"; +"eh.setting.view.description.excludedUploaders" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; +"eh.setting.view.description.excludedUploadersCount" = "You are currently using **%@ / %@** exclusion slots."; + +"eh.setting.view.section.title.searchResultCount" = "Search Result Count"; +"eh.setting.view.title.resultCount" = "Result count"; +"eh.setting.view.description.resultCount" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; + +"eh.setting.view.section.title.thumbnailSettings" = "Thumbnail Settings"; +"eh.setting.view.title.thumbnailLoadTiming" = "Thumbnail load timing"; +"eh.setting.view.description.thumbnailLoadTiming" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; +"eh.setting.view.description.thumbnailConfiguration" = "You can set a default thumbnail configuration for all galleries you visit."; +"eh.setting.view.title.thumbnailSize" = "Size"; +"eh.setting.view.title.thumbnailRowCount" = "Rows"; +// EhSetting.ThumbnailLoadTiming +"enum.eh.setting.thumbnail.load.timing.value.onMouseOver" = "On mouse-over"; +"enum.eh.setting.thumbnail.load.timing.value.onPageLoad" = "On page load"; +"enum.eh.setting.thumbnail.load.timing.description.onMouseOver" = "Pages load faster, but there may be a slight delay before a thumb appears."; +"enum.eh.setting.thumbnail.load.timing.description.onPageLoad" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; +// EhSetting.ThumbnailSize +"enum.eh.setting.thumbnail.size.value.normal" = "Normal"; +"enum.eh.setting.thumbnail.size.value.large" = "Large"; + +"eh.setting.view.section.title.thumbnailScaling" = "Thumbnail Scaling"; +"eh.setting.view.title.scaleFactor" = "Scale factor"; +"eh.setting.view.description.scaleFactor" = "Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%."; + +"eh.setting.view.section.title.viewportOverride" = "Viewport Override"; +"eh.setting.view.title.virtualWidth" = "Virtual width"; +"eh.setting.view.description.virtualWidth" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400."; + +"eh.setting.view.section.title.galleryComments" = "Gallery Comments"; +"eh.setting.view.title.commentsSortOrder" = "Comments sort order"; +"eh.setting.view.title.commentsVotesShowTiming" = "Comment votes show timing"; +// EhSetting.CommentsSortOrder +"enum.eh.setting.comments.sort.order.value.oldest" = "Oldest comments first"; +"enum.eh.setting.comments.sort.order.value.recent" = "Recent comments first"; +"enum.eh.setting.comments.sort.order.value.highestScore" = "By highest score"; +// EhSetting.CommentVotesShowTiming +"enum.eh.setting.comments.votes.show.timing.value.onHoverOrClick" = "On score hover or click"; +"enum.eh.setting.comments.votes.show.timing.value.always" = "Always"; + +"eh.setting.view.section.title.galleryTags" = "Gallery Tags"; +"eh.setting.view.title.tagsSortOrder" = "Tags sort order"; +// EhSetting.TagsSortOrder +"enum.eh.setting.tags.sort.order.value.alphabetical" = "Alphabetical"; +"enum.eh.setting.tags.sort.order.value.tagPower" = "By tag power"; + +"eh.setting.view.section.title.galleryPageNumbering" = "Gallery Page Numbering"; +"eh.setting.view.title.showGalleryPageNumbers" = "Show gallery page numbers"; + +"eh.setting.view.section.title.hathLocalNetworkHost" = "Hath Local Network Host"; +"eh.setting.view.title.ipAddressPort" = "IP address:Port"; +"eh.setting.view.description.ipAddressPort" = "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work."; + +"eh.setting.view.section.title.originalImages" = "Original Images"; +"eh.setting.view.title.useOriginalImages" = "Use original images"; + +"eh.setting.view.section.title.multiPageViewer" = "Multi-Page Viewer"; +"eh.setting.view.title.useMultiPageViewer" = "Use Multi-Page Viewer"; +"eh.setting.view.title.displayStyle" = "Display style"; +"eh.setting.view.title.showThumbnailPane" = "Show thumbnail pane"; +// EhSetting.MultiplePageViewerStyle +"enum.eh.setting.multiple.page.viewer.style.value.alignLeftScaleIfOverWidth" = "Align left, scale if overwidth"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterScaleIfOverWidth" = "Align center, scale if overwidth"; +"enum.eh.setting.multiple.page.viewer.style.value.alignCenterAlwaysScale" = "Align center, always scale"; // MARK: Category -"Doujinshi" = "同人誌"; -"Manga" = "漫畫"; -"Artist CG" = "插畫"; -"Game CG" = "遊戲 CG"; -"Western" = "西方"; -"Non-H" = "健康"; -"Image Set" = "圖片集"; -"Cosplay" = "角色扮演"; -"Asian Porn" = "亞洲"; -"Misc" = "其它"; -//"Private" = ""; +"enum.category.value.doujinshi" = "同人誌"; +"enum.category.value.manga" = "漫畫"; +"enum.category.value.artistCG" = "插畫"; +"enum.category.value.gameCG" = "遊戲 CG"; +"enum.category.value.western" = "西方"; +"enum.category.value.nonH" = "健康"; +"enum.category.value.imageSet" = "圖片集"; +"enum.category.value.cosplay" = "角色扮演"; +"enum.category.value.asianPorn" = "亞洲"; +"enum.category.value.misc" = "其它"; +"enum.category.value.private" = "Private"; // MARK: TagCategory -"Reclass" = "重新分類"; -"Language" = "語言"; -"Parody" = "原作"; -"Character" = "角色"; -"Group" = "團體"; -"Artist" = "作者"; -"Male" = "男性"; -"Female" = "女性"; -//"Mixed" = ""; -//"Cosplayer" = ""; -//"Other" = ""; -//"Temp" = ""; - -// MARK: IconType -"Normal" = "普通"; -"Default" = "預設"; -"Weird" = "詭異"; - -// MARK: PreferredColorScheme -"Automatic" = "自動"; -"Light" = "淺色"; -"Dark" = "深色"; - -// MARK: AutoLockPolicy -"Never" = "不鎖定"; -"Instantly" = "立即"; -"%lld seconds" = "%lld 秒"; -"%lld minute" = "%lld 分鐘"; -"%lld minutes" = "%lld 分鐘"; +"enum.tag.category.value.reclass" = "重新分類"; +"enum.tag.category.value.language" = "語言"; +"enum.tag.category.value.parody" = "原作"; +"enum.tag.category.value.character" = "角色"; +"enum.tag.category.value.group" = "團體"; +"enum.tag.category.value.artist" = "作者"; +"enum.tag.category.value.male" = "男性"; +"enum.tag.category.value.female" = "女性"; +"enum.tag.category.value.mixed" = "Mixed"; +"enum.tag.category.value.cosplayer" = "Cosplayer"; +"enum.tag.category.value.other" = "Other"; +"enum.tag.category.value.temp" = "Temp"; // MARK: Language -"LANGUAGE_OTHER" = "其它"; -"LANGUAGE_INVALID" = "無效"; - -"Afrikaans" = "南非語"; "Albanian" = "阿爾巴尼亞語"; "Arabic" = "阿拉伯語"; - -"Bengali" = "孟加拉語"; "Bosnian" = "波士尼亞語"; "Bulgarian" = "保加利亞語"; "Burmese" = "緬甸語"; - -"Catalan" = "加泰羅尼亞語"; "Cebuano" = "宿霧語"; "Chinese" = "漢語"; "Croatian" = "克羅地亞語"; "Czech" = "捷克語"; - -"Danish" = "丹麥語"; "Dutch" = "荷蘭語"; - -"English" = "英語"; "Esperanto" = "國際語"; "Estonian" = "愛沙尼亞語"; - -"Finnish" = "芬蘭語"; "French" = "法語"; - -"Georgian" = "格魯吉亞語"; "German" = "德語"; "Greek" = "希臘語"; - -"Hebrew" = "希伯來語"; "Hindi" = "印地語"; "Hmong" = "苗語"; "Hungarian" = "匈牙利語"; - -"Indonesian" = "印尼語"; "Italian" = "意大利語"; - -"Japanese" = "日語"; - -"Kazakh" = "哈薩克語"; "Khmer" = "高棉語"; "Korean" = "韓語"; "Kurdish" = "庫爾德語"; - -"Lao" = "老撾語"; "Latin" = "拉丁語"; - -"Mongolian" = "蒙古語"; - -"Ndebele" = "恩德貝萊語"; "Nepali" = "尼泊爾語"; "Norwegian" = "挪威語"; - -"Oromo" = "奧羅莫語"; - -"Pashto" = "普什圖語"; "Persian" = "波斯語"; "Polish" = "波蘭語"; "Portuguese" = "葡萄牙語"; "Punjabi" = "旁遮普語"; - -"Romanian" = "羅馬尼亞語"; "Russian" = "俄語"; - -"Sango" = "桑戈語"; "Serbian" = "塞爾維亞語"; "Shona" = "紹納語"; "Slovak" = "斯洛伐克語"; "Slovenian" = "斯洛文尼亞語"; "Somali" = "索馬里語"; "Spanish" = "西班牙語"; "Swahili" = "斯瓦希里語"; "Swedish" = "瑞典語"; - -"Tagalog" = "他加洛語"; "Thai" = "泰語"; "Tigrinya" = "提格利尼亞語"; "Turkish" = "土耳其語"; - -"Ukrainian" = "烏克蘭語"; "Urdu" = "烏爾都語"; - -"Vietnamese" = "越南語"; - -"Zulu" = "祖魯語"; - -// MARK: EhSettingCountry -//"Auto-Detect" = ""; "Afghanistan" = ""; "Aland Islands" = ""; "Albania" = ""; "Algeria" = ""; "American Samoa" = ""; "Andorra" = ""; "Angola" = ""; "Anguilla" = ""; "Antarctica" = ""; "Antigua and Barbuda" = ""; "Argentina" = ""; "Armenia" = ""; "Aruba" = ""; "Asia-Pacific Region" = ""; "Australia" = ""; "Austria" = ""; "Azerbaijan" = ""; "Bahamas" = ""; "Bahrain" = ""; "Bangladesh" = ""; "Barbados" = ""; "Belarus" = ""; "Belgium" = ""; "Belize" = ""; "Benin" = ""; "Bermuda" = ""; "Bhutan" = ""; "Bolivia" = ""; "Bonaire Saint Eustatius and Saba" = ""; "Bosnia and Herzegovina" = ""; "Botswana" = ""; "Bouvet Island" = ""; "Brazil" = ""; "British Indian Ocean Territory" = ""; "Brunei Darussalam" = ""; "Bulgaria" = ""; "Burkina Faso" = ""; "Burundi" = ""; "Cambodia" = ""; "Cameroon" = ""; "Canada" = ""; "Cape Verde" = ""; "Cayman Islands" = ""; "Central African Republic" = ""; "Chad" = ""; "Chile" = ""; "China" = ""; "Christmas Island" = ""; "Cocos Islands" = ""; "Colombia" = ""; "Comoros" = ""; "Congo" = ""; "The Democratic Republic of the Congo" = ""; "Cook Islands" = ""; "Costa Rica" = ""; "Cote D'Ivoire" = ""; "Croatia" = ""; "Cuba" = ""; "Curacao" = ""; "Cyprus" = ""; "Czech Republic" = ""; "Denmark" = ""; "Djibouti" = ""; "Dominica" = ""; "Dominican Republic" = ""; "Ecuador" = ""; "Egypt" = ""; "El Salvador" = ""; "Equatorial Guinea" = ""; "Eritrea" = ""; "Estonia" = ""; "Ethiopia" = ""; "Europe" = ""; "Falkland Islands" = ""; "Faroe Islands" = ""; "Fiji" = ""; "Finland" = ""; "France" = ""; "French Guiana" = ""; "French Polynesia" = ""; "French Southern Territories" = ""; "Gabon" = ""; "Gambia" = ""; "Georgia" = ""; "Germany" = ""; "Ghana" = ""; "Gibraltar" = ""; "Greece" = ""; "Greenland" = ""; "Grenada" = ""; "Guadeloupe" = ""; "Guam" = ""; "Guatemala" = ""; "Guernsey" = ""; "Guinea" = ""; "Guinea-Bissau" = ""; "Guyana" = ""; "Haiti" = ""; "Heard Island and McDonald Islands" = ""; "Vatican City State" = ""; "Honduras" = ""; "Hong Kong" = ""; "Hungary" = ""; "Iceland" = ""; "India" = ""; "Indonesia" = ""; "Iran" = ""; "Iraq" = ""; "Ireland" = ""; "Isle of Man" = ""; "Israel" = ""; "Italy" = ""; "Jamaica" = ""; "Japan" = ""; "Jersey" = ""; "Jordan" = ""; "Kazakhstan" = ""; "Kenya" = ""; "Kiribati" = ""; "Kuwait" = ""; "Kyrgyzstan" = ""; "Lao People's Democratic Republic" = ""; "Latvia" = ""; "Lebanon" = ""; "Lesotho" = ""; "Liberia" = ""; "Libya" = ""; "Liechtenstein" = ""; "Lithuania" = ""; "Luxembourg" = ""; "Macau" = ""; "Macedonia" = ""; "Madagascar" = ""; "Malawi" = ""; "Malaysia" = ""; "Maldives" = ""; "Mali" = ""; "Malta" = ""; "Marshall Islands" = ""; "Martinique" = ""; "Mauritania" = ""; "Mauritius" = ""; "Mayotte" = ""; "Mexico" = ""; "Micronesia" = ""; "Moldova" = ""; "Monaco" = ""; "Mongolia" = ""; "Montenegro" = ""; "Montserrat" = ""; "Morocco" = ""; "Mozambique" = ""; "Myanmar" = ""; "Namibia" = ""; "Nauru" = ""; "Nepal" = ""; "Netherlands" = ""; "New Caledonia" = ""; "New Zealand" = ""; "Nicaragua" = ""; "Niger" = ""; "Nigeria" = ""; "Niue" = ""; "Norfolk Island" = ""; "North Korea" = ""; "Northern Mariana Islands" = ""; "Norway" = ""; "Oman" = ""; "Pakistan" = ""; "Palau" = ""; "Palestinian Territory" = ""; "Panama" = ""; "Papua New Guinea" = ""; "Paraguay" = ""; "Peru" = ""; "Philippines" = ""; "Pitcairn Islands" = ""; "Poland" = ""; "Portugal" = ""; "Puerto Rico" = ""; "Qatar" = ""; "Reunion" = ""; "Romania" = ""; "Russian Federation" = ""; "Rwanda" = ""; "Saint Barthelemy" = ""; "Saint Helena" = ""; "Saint Kitts and Nevis" = ""; "Saint Lucia" = ""; "Saint Martin" = ""; "Saint Pierre and Miquelon" = ""; "Saint Vincent and the Grenadines" = ""; "Samoa" = ""; "San Marino" = ""; "Sao Tome and Principe" = ""; "Saudi Arabia" = ""; "Senegal" = ""; "Serbia" = ""; "Seychelles" = ""; "Sierra Leone" = ""; "Singapore" = ""; "Sint Maarten" = ""; "Slovakia" = ""; "Slovenia" = ""; "Solomon Islands" = ""; "Somalia" = ""; "South Africa" = ""; "South Georgia and the South Sandwich Islands" = ""; "South Korea" = ""; "South Sudan" = ""; "Spain" = ""; "Sri Lanka" = ""; "Sudan" = ""; "Suriname" = ""; "Svalbard and Jan Mayen" = ""; "Swaziland" = ""; "Sweden" = ""; "Switzerland" = ""; "Syrian Arab Republic" = ""; "Taiwan" = ""; "Tajikistan" = ""; "Tanzania" = ""; "Thailand" = ""; "Timor-Leste" = ""; "Togo" = ""; "Tokelau" = ""; "Tonga" = ""; "Trinidad and Tobago" = ""; "Tunisia" = ""; "Turkey" = ""; "Turkmenistan" = ""; "Turks and Caicos Islands" = ""; "Tuvalu" = ""; "Uganda" = ""; "Ukraine" = ""; "United Arab Emirates" = ""; "United Kingdom" = ""; "United States" = ""; "United States Minor Outlying Islands" = ""; "Uruguay" = ""; "Uzbekistan" = ""; "Vanuatu" = ""; "Venezuela" = ""; "Vietnam" = ""; "British Virgin Islands" = ""; "U.S. Virgin Islands" = ""; "Wallis and Futuna" = ""; "Western Sahara" = ""; "Yemen" = ""; "Zambia" = ""; "Zimbabwe" = ""; +"enum.language.value.invalid" = "無效"; +"enum.language.value.other" = "其它"; +"enum.language.value.afrikaans" = "南非語"; +"enum.language.value.albanian" = "阿爾巴尼亞語"; +"enum.language.value.arabic" = "阿拉伯語"; +"enum.language.value.bengali" = "孟加拉語"; +"enum.language.value.bosnian" = "波士尼亞語"; +"enum.language.value.bulgarian" = "保加利亞語"; +"enum.language.value.burmese" = "緬甸語"; +"enum.language.value.catalan" = "加泰羅尼亞語"; +"enum.language.value.cebuano" = "宿霧語"; +"enum.language.value.chinese" = "漢語"; +"enum.language.value.croatian" = "克羅地亞語"; +"enum.language.value.czech" = "捷克語"; +"enum.language.value.danish" = "丹麥語"; +"enum.language.value.dutch" = "荷蘭語"; +"enum.language.value.english" = "英語"; +"enum.language.value.esperanto" = "國際語"; +"enum.language.value.estonian" = "愛沙尼亞語"; +"enum.language.value.finnish" = "芬蘭語"; +"enum.language.value.french" = "法語"; +"enum.language.value.georgian" = "格魯吉亞語"; +"enum.language.value.german" = "德語"; +"enum.language.value.greek" = "希臘語"; +"enum.language.value.hebrew" = "希伯來語"; +"enum.language.value.hindi" = "印地語"; +"enum.language.value.hmong" = "苗語"; +"enum.language.value.hungarian" = "匈牙利語"; +"enum.language.value.indonesian" = "印尼語"; +"enum.language.value.italian" = "意大利語"; +"enum.language.value.japanese" = "日語"; +"enum.language.value.kazakh" = "哈薩克語"; +"enum.language.value.khmer" = "高棉語"; +"enum.language.value.korean" = "韓語"; +"enum.language.value.kurdish" = "庫爾德語"; +"enum.language.value.lao" = "老撾語"; +"enum.language.value.latin" = "拉丁語"; +"enum.language.value.mongolian" = "蒙古語"; +"enum.language.value.ndebele" = "恩德貝萊語"; +"enum.language.value.nepali" = "尼泊爾語"; +"enum.language.value.norwegian" = "挪威語"; +"enum.language.value.oromo" = "奧羅莫語"; +"enum.language.value.pashto" = "普什圖語"; +"enum.language.value.persian" = "波斯語"; +"enum.language.value.polish" = "波蘭語"; +"enum.language.value.portuguese" = "葡萄牙語"; +"enum.language.value.punjabi" = "旁遮普語"; +"enum.language.value.romanian" = "羅馬尼亞語"; +"enum.language.value.russian" = "俄語"; +"enum.language.value.sango" = "桑戈語"; +"enum.language.value.serbian" = "塞爾維亞語"; +"enum.language.value.shona" = "紹納語"; +"enum.language.value.slovak" = "斯洛伐克語"; +"enum.language.value.slovenian" = "斯洛文尼亞語"; +"enum.language.value.somali" = "索馬里語"; +"enum.language.value.spanish" = "西班牙語"; +"enum.language.value.swahili" = "斯瓦希里語"; +"enum.language.value.swedish" = "瑞典語"; +"enum.language.value.tagalog" = "他加洛語"; +"enum.language.value.thai" = "泰語"; +"enum.language.value.tigrinya" = "提格利尼亞語"; +"enum.language.value.turkish" = "土耳其語"; +"enum.language.value.ukrainian" = "烏克蘭語"; +"enum.language.value.urdu" = "烏爾都語"; +"enum.language.value.vietnamese" = "越南語"; +"enum.language.value.zulu" = "祖魯語"; + +// MARK: BrowsingCountry +"enum.browsing.country.name.autoDetect" = "Auto-Detect"; +"enum.browsing.country.name.afghanistan" = "Afghanistan"; +"enum.browsing.country.name.alandIslands" = "Aland Islands"; +"enum.browsing.country.name.albania" = "Albania"; +"enum.browsing.country.name.algeria" = "Algeria"; +"enum.browsing.country.name.americanSamoa" = "American Samoa"; +"enum.browsing.country.name.andorra" = "Andorra"; +"enum.browsing.country.name.angola" = "Angola"; +"enum.browsing.country.name.anguilla" = "Anguilla"; +"enum.browsing.country.name.antarctica" = "Antarctica"; +"enum.browsing.country.name.antiguaAndBarbuda" = "Antigua and Barbuda"; +"enum.browsing.country.name.argentina" = "Argentina"; +"enum.browsing.country.name.armenia" = "Armenia"; +"enum.browsing.country.name.aruba" = "Aruba"; +"enum.browsing.country.name.asiaPacificRegion" = "Asia-Pacific Region"; +"enum.browsing.country.name.australia" = "Australia"; +"enum.browsing.country.name.austria" = "Austria"; +"enum.browsing.country.name.azerbaijan" = "Azerbaijan"; +"enum.browsing.country.name.bahamas" = "Bahamas"; +"enum.browsing.country.name.bahrain" = "Bahrain"; +"enum.browsing.country.name.bangladesh" = "Bangladesh"; +"enum.browsing.country.name.barbados" = "Barbados"; +"enum.browsing.country.name.belarus" = "Belarus"; +"enum.browsing.country.name.belgium" = "Belgium"; +"enum.browsing.country.name.belize" = "Belize"; +"enum.browsing.country.name.benin" = "Benin"; +"enum.browsing.country.name.bermuda" = "Bermuda"; +"enum.browsing.country.name.bhutan" = "Bhutan"; +"enum.browsing.country.name.bolivia" = "Bolivia"; +"enum.browsing.country.name.bonaireSaintEustatiusAndSaba" = "Bonaire Saint Eustatius and Saba"; +"enum.browsing.country.name.bosniaAndHerzegovina" = "Bosnia and Herzegovina"; +"enum.browsing.country.name.botswana" = "Botswana"; +"enum.browsing.country.name.bouvetIsland" = "Bouvet Island"; +"enum.browsing.country.name.brazil" = "Brazil"; +"enum.browsing.country.name.britishIndianOceanTerritory" = "British Indian Ocean Territory"; +"enum.browsing.country.name.bruneiDarussalam" = "Brunei Darussalam"; +"enum.browsing.country.name.bulgaria" = "Bulgaria"; +"enum.browsing.country.name.burkinaFaso" = "Burkina Faso"; +"enum.browsing.country.name.burundi" = "Burundi"; +"enum.browsing.country.name.cambodia" = "Cambodia"; +"enum.browsing.country.name.cameroon" = "Cameroon"; +"enum.browsing.country.name.canada" = "Canada"; +"enum.browsing.country.name.capeVerde" = "Cape Verde"; +"enum.browsing.country.name.caymanIslands" = "Cayman Islands"; +"enum.browsing.country.name.centralAfricanRepublic" = "Central African Republic"; +"enum.browsing.country.name.chad" = "Chad"; +"enum.browsing.country.name.chile" = "Chile"; +"enum.browsing.country.name.china" = "China"; +"enum.browsing.country.name.christmasIsland" = "Christmas Island"; +"enum.browsing.country.name.cocosIslands" = "Cocos Islands"; +"enum.browsing.country.name.colombia" = "Colombia"; +"enum.browsing.country.name.comoros" = "Comoros"; +"enum.browsing.country.name.congo" = "Congo"; +"enum.browsing.country.name.theDemocraticRepublicOfTheCongo" = "The Democratic Republic of the Congo"; +"enum.browsing.country.name.cookIslands" = "Cook Islands"; +"enum.browsing.country.name.costaRica" = "Costa Rica"; +"enum.browsing.country.name.coteDIvoire" = "Cote D'Ivoire"; +"enum.browsing.country.name.croatia" = "Croatia"; +"enum.browsing.country.name.cuba" = "Cuba"; +"enum.browsing.country.name.curacao" = "Curacao"; +"enum.browsing.country.name.cyprus" = "Cyprus"; +"enum.browsing.country.name.czechRepublic" = "Czech Republic"; +"enum.browsing.country.name.denmark" = "Denmark"; +"enum.browsing.country.name.djibouti" = "Djibouti"; +"enum.browsing.country.name.dominica" = "Dominica"; +"enum.browsing.country.name.dominicanRepublic" = "Dominican Republic"; +"enum.browsing.country.name.ecuador" = "Ecuador"; +"enum.browsing.country.name.egypt" = "Egypt"; +"enum.browsing.country.name.elSalvador" = "El Salvador"; +"enum.browsing.country.name.equatorialGuinea" = "Equatorial Guinea"; +"enum.browsing.country.name.eritrea" = "Eritrea"; +"enum.browsing.country.name.estonia" = "Estonia"; +"enum.browsing.country.name.ethiopia" = "Ethiopia"; +"enum.browsing.country.name.europe" = "Europe"; +"enum.browsing.country.name.falklandIslands" = "Falkland Islands"; +"enum.browsing.country.name.faroeIslands" = "Faroe Islands"; +"enum.browsing.country.name.fiji" = "Fiji"; +"enum.browsing.country.name.finland" = "Finland"; +"enum.browsing.country.name.france" = "France"; +"enum.browsing.country.name.frenchGuiana" = "French Guiana"; +"enum.browsing.country.name.frenchPolynesia" = "French Polynesia"; +"enum.browsing.country.name.frenchSouthernTerritories" = "French Southern Territories"; +"enum.browsing.country.name.gabon" = "Gabon"; +"enum.browsing.country.name.gambia" = "Gambia"; +"enum.browsing.country.name.georgia" = "Georgia"; +"enum.browsing.country.name.germany" = "Germany"; +"enum.browsing.country.name.ghana" = "Ghana"; +"enum.browsing.country.name.gibraltar" = "Gibraltar"; +"enum.browsing.country.name.greece" = "Greece"; +"enum.browsing.country.name.greenland" = "Greenland"; +"enum.browsing.country.name.grenada" = "Grenada"; +"enum.browsing.country.name.guadeloupe" = "Guadeloupe"; +"enum.browsing.country.name.guam" = "Guam"; +"enum.browsing.country.name.guatemala" = "Guatemala"; +"enum.browsing.country.name.guernsey" = "Guernsey"; +"enum.browsing.country.name.guinea" = "Guinea"; +"enum.browsing.country.name.guineaBissau" = "Guinea-Bissau"; +"enum.browsing.country.name.guyana" = "Guyana"; +"enum.browsing.country.name.haiti" = "Haiti"; +"enum.browsing.country.name.heardIslandAndMcDonaldIslands" = "Heard Island and McDonald Islands"; +"enum.browsing.country.name.vaticanCityState" = "Vatican City State"; +"enum.browsing.country.name.honduras" = "Honduras"; +"enum.browsing.country.name.hongKong" = "Hong Kong"; +"enum.browsing.country.name.hungary" = "Hungary"; +"enum.browsing.country.name.iceland" = "Iceland"; +"enum.browsing.country.name.india" = "India"; +"enum.browsing.country.name.indonesia" = "Indonesia"; +"enum.browsing.country.name.iran" = "Iran"; +"enum.browsing.country.name.iraq" = "Iraq"; +"enum.browsing.country.name.ireland" = "Ireland"; +"enum.browsing.country.name.isleOfMan" = "Isle of Man"; +"enum.browsing.country.name.israel" = "Israel"; +"enum.browsing.country.name.italy" = "Italy"; +"enum.browsing.country.name.jamaica" = "Jamaica"; +"enum.browsing.country.name.japan" = "Japan"; +"enum.browsing.country.name.jersey" = "Jersey"; +"enum.browsing.country.name.jordan" = "Jordan"; +"enum.browsing.country.name.kazakhstan" = "Kazakhstan"; +"enum.browsing.country.name.kenya" = "Kenya"; +"enum.browsing.country.name.kiribati" = "Kiribati"; +"enum.browsing.country.name.kuwait" = "Kuwait"; +"enum.browsing.country.name.kyrgyzstan" = "Kyrgyzstan"; +"enum.browsing.country.name.laoPeoplesDemocraticRepublic" = "Lao People's Democratic Republic"; +"enum.browsing.country.name.latvia" = "Latvia"; +"enum.browsing.country.name.lebanon" = "Lebanon"; +"enum.browsing.country.name.lesotho" = "Lesotho"; +"enum.browsing.country.name.liberia" = "Liberia"; +"enum.browsing.country.name.libya" = "Libya"; +"enum.browsing.country.name.liechtenstein" = "Liechtenstein"; +"enum.browsing.country.name.lithuania" = "Lithuania"; +"enum.browsing.country.name.luxembourg" = "Luxembourg"; +"enum.browsing.country.name.macau" = "Macau"; +"enum.browsing.country.name.macedonia" = "Macedonia"; +"enum.browsing.country.name.madagascar" = "Madagascar"; +"enum.browsing.country.name.malawi" = "Malawi"; +"enum.browsing.country.name.malaysia" = "Malaysia"; +"enum.browsing.country.name.maldives" = "Maldives"; +"enum.browsing.country.name.mali" = "Mali"; +"enum.browsing.country.name.malta" = "Malta"; +"enum.browsing.country.name.marshallIslands" = "Marshall Islands"; +"enum.browsing.country.name.martinique" = "Martinique"; +"enum.browsing.country.name.mauritania" = "Mauritania"; +"enum.browsing.country.name.mauritius" = "Mauritius"; +"enum.browsing.country.name.mayotte" = "Mayotte"; +"enum.browsing.country.name.mexico" = "Mexico"; +"enum.browsing.country.name.micronesia" = "Micronesia"; +"enum.browsing.country.name.moldova" = "Moldova"; +"enum.browsing.country.name.monaco" = "Monaco"; +"enum.browsing.country.name.mongolia" = "Mongolia"; +"enum.browsing.country.name.montenegro" = "Montenegro"; +"enum.browsing.country.name.montserrat" = "Montserrat"; +"enum.browsing.country.name.morocco" = "Morocco"; +"enum.browsing.country.name.mozambique" = "Mozambique"; +"enum.browsing.country.name.myanmar" = "Myanmar"; +"enum.browsing.country.name.namibia" = "Namibia"; +"enum.browsing.country.name.nauru" = "Nauru"; +"enum.browsing.country.name.nepal" = "Nepal"; +"enum.browsing.country.name.netherlands" = "Netherlands"; +"enum.browsing.country.name.newCaledonia" = "New Caledonia"; +"enum.browsing.country.name.newZealand" = "New Zealand"; +"enum.browsing.country.name.nicaragua" = "Nicaragua"; +"enum.browsing.country.name.niger" = "Niger"; +"enum.browsing.country.name.nigeria" = "Nigeria"; +"enum.browsing.country.name.niue" = "Niue"; +"enum.browsing.country.name.norfolkIsland" = "Norfolk Island"; +"enum.browsing.country.name.northKorea" = "North Korea"; +"enum.browsing.country.name.northernMarianaIslands" = "Northern Mariana Islands"; +"enum.browsing.country.name.norway" = "Norway"; +"enum.browsing.country.name.oman" = "Oman"; +"enum.browsing.country.name.pakistan" = "Pakistan"; +"enum.browsing.country.name.palau" = "Palau"; +"enum.browsing.country.name.palestinianTerritory" = "Palestinian Territory"; +"enum.browsing.country.name.panama" = "Panama"; +"enum.browsing.country.name.papuaNewGuinea" = "Papua New Guinea"; +"enum.browsing.country.name.paraguay" = "Paraguay"; +"enum.browsing.country.name.peru" = "Peru"; +"enum.browsing.country.name.philippines" = "Philippines"; +"enum.browsing.country.name.pitcairnIslands" = "Pitcairn Islands"; +"enum.browsing.country.name.poland" = "Poland"; +"enum.browsing.country.name.portugal" = "Portugal"; +"enum.browsing.country.name.puertoRico" = "Puerto Rico"; +"enum.browsing.country.name.qatar" = "Qatar"; +"enum.browsing.country.name.reunion" = "Reunion"; +"enum.browsing.country.name.romania" = "Romania"; +"enum.browsing.country.name.russianFederation" = "Russian Federation"; +"enum.browsing.country.name.rwanda" = "Rwanda"; +"enum.browsing.country.name.saintBarthelemy" = "Saint Barthelemy"; +"enum.browsing.country.name.saintHelena" = "Saint Helena"; +"enum.browsing.country.name.saintKittsAndNevis" = "Saint Kitts and Nevis"; +"enum.browsing.country.name.saintLucia" = "Saint Lucia"; +"enum.browsing.country.name.saintMartin" = "Saint Martin"; +"enum.browsing.country.name.saintPierreAndMiquelon" = "Saint Pierre and Miquelon"; +"enum.browsing.country.name.saintVincentAndTheGrenadines" = "Saint Vincent and the Grenadines"; +"enum.browsing.country.name.samoa" = "Samoa"; +"enum.browsing.country.name.sanMarino" = "San Marino"; +"enum.browsing.country.name.saoTomeAndPrincipe" = "Sao Tome and Principe"; +"enum.browsing.country.name.saudiArabia" = "Saudi Arabia"; +"enum.browsing.country.name.senegal" = "Senegal"; +"enum.browsing.country.name.serbia" = "Serbia"; +"enum.browsing.country.name.seychelles" = "Seychelles"; +"enum.browsing.country.name.sierraLeone" = "Sierra Leone"; +"enum.browsing.country.name.singapore" = "Singapore"; +"enum.browsing.country.name.sintMaarten" = "Sint Maarten"; +"enum.browsing.country.name.slovakia" = "Slovakia"; +"enum.browsing.country.name.slovenia" = "Slovenia"; +"enum.browsing.country.name.solomonIslands" = "Solomon Islands"; +"enum.browsing.country.name.somalia" = "Somalia"; +"enum.browsing.country.name.southAfrica" = "South Africa"; +"enum.browsing.country.name.southGeorgiaAndTheSouthSandwichIslands" = "South Georgia and the South Sandwich Islands"; +"enum.browsing.country.name.southKorea" = "South Korea"; +"enum.browsing.country.name.southSudan" = "South Sudan"; +"enum.browsing.country.name.spain" = "Spain"; +"enum.browsing.country.name.sriLanka" = "Sri Lanka"; +"enum.browsing.country.name.sudan" = "Sudan"; +"enum.browsing.country.name.suriname" = "Suriname"; +"enum.browsing.country.name.svalbardAndJanMayen" = "Svalbard and Jan Mayen"; +"enum.browsing.country.name.swaziland" = "Swaziland"; +"enum.browsing.country.name.sweden" = "Sweden"; +"enum.browsing.country.name.switzerland" = "Switzerland"; +"enum.browsing.country.name.syrianArabRepublic" = "Syrian Arab Republic"; +"enum.browsing.country.name.taiwan" = "Taiwan"; +"enum.browsing.country.name.tajikistan" = "Tajikistan"; +"enum.browsing.country.name.tanzania" = "Tanzania"; +"enum.browsing.country.name.thailand" = "Thailand"; +"enum.browsing.country.name.timorLeste" = "Timor-Leste"; +"enum.browsing.country.name.togo" = "Togo"; +"enum.browsing.country.name.tokelau" = "Tokelau"; +"enum.browsing.country.name.tonga" = "Tonga"; +"enum.browsing.country.name.trinidadAndTobago" = "Trinidad and Tobago"; +"enum.browsing.country.name.tunisia" = "Tunisia"; +"enum.browsing.country.name.turkey" = "Turkey"; +"enum.browsing.country.name.turkmenistan" = "Turkmenistan"; +"enum.browsing.country.name.turksAndCaicosIslands" = "Turks and Caicos Islands"; +"enum.browsing.country.name.tuvalu" = "Tuvalu"; +"enum.browsing.country.name.uganda" = "Uganda"; +"enum.browsing.country.name.ukraine" = "Ukraine"; +"enum.browsing.country.name.unitedArabEmirates" = "United Arab Emirates"; +"enum.browsing.country.name.unitedKingdom" = "United Kingdom"; +"enum.browsing.country.name.unitedStates" = "United States"; +"enum.browsing.country.name.unitedStatesMinorOutlyingIslands" = "United States Minor Outlying Islands"; +"enum.browsing.country.name.uruguay" = "Uruguay"; +"enum.browsing.country.name.uzbekistan" = "Uzbekistan"; +"enum.browsing.country.name.vanuatu" = "Vanuatu"; +"enum.browsing.country.name.venezuela" = "Venezuela"; +"enum.browsing.country.name.vietnam" = "Vietnam"; +"enum.browsing.country.name.virginIslandsBritish" = "British Virgin Islands"; +"enum.browsing.country.name.virginIslandsUS" = "U.S. Virgin Islands"; +"enum.browsing.country.name.wallisAndFutuna" = "Wallis and Futuna"; +"enum.browsing.country.name.westernSahara" = "Western Sahara"; +"enum.browsing.country.name.yemen" = "Yemen"; +"enum.browsing.country.name.zambia" = "Zambia"; +"enum.browsing.country.name.zimbabwe" = "Zimbabwe"; diff --git a/EhPanda/DataFlow/AppAction.swift b/EhPanda/DataFlow/AppAction.swift deleted file mode 100644 index 1fc8d261..00000000 --- a/EhPanda/DataFlow/AppAction.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// AppAction.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/12/26. -// - -import UIKit -import Kanna -import Foundation - -enum AppAction { - // swiftlint:disable line_length - case resetUser - case resetHomeInfo - case resetFilter(range: FilterRange) - case doFinishLoginTasks - case setReadingProgress(gid: String, tag: Int) - case setAppIconType(_ iconType: IconType) - case appendHistoryKeywords(texts: [String]) - case removeHistoryKeyword(text: String) - case clearHistoryKeywords - case setViewControllersCount - case setSetting(_ setting: Setting) - case setGalleryCommentJumpID(gid: String?) - case setSlideMenuClosed(closed: Bool) - case fulfillGalleryPreviews(gid: String) - case fulfillGalleryContents(gid: String) - case setPendingJumpInfos(gid: String, pageIndex: Int?, commentID: String?) - case appendQuickSearchWord - case deleteQuickSearchWord(offsets: IndexSet) - case modifyQuickSearchWord(newWord: QuickSearchWord) - case moveQuickSearchWord(source: IndexSet, destination: Int) - - case setAppLock(activated: Bool) - case setBlurEffect(activated: Bool) - case setHomeListType(_ type: HomeListType) - case setFavoritesIndex(_ index: Int) - case setToplistsType(_ type: ToplistsType) - case setNavigationBarHidden(_ hidden: Bool) - case setHomeViewSheetState(_ state: HomeViewSheetState?) - case setSettingViewSheetState(_ state: SettingViewSheetState?) - case setDetailViewSheetState(_ state: DetailViewSheetState?) - case setCommentViewSheetState(_ state: CommentViewSheetState?) - - case handleJumpPage(index: Int, keyword: String? = nil) - case fetchIgneous - case fetchTagTranslator - case fetchTagTranslatorDone(result: Result) - case fetchGreeting - case fetchGreetingDone(result: Result) - case fetchUserInfo - case fetchUserInfoDone(result: Result) - case fetchFavoriteNames - case fetchFavoriteNamesDone(result: Result<[Int: String], AppError>) - case fetchGalleryItemReverse(url: String, shouldParseGalleryURL: Bool) - case fetchGalleryItemReverseDone(carriedValue: String, result: Result) - case fetchSearchItems(keyword: String, pageNum: Int? = nil) - case fetchSearchItemsDone(result: Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreSearchItems(keyword: String) - case fetchMoreSearchItemsDone(result: Result<(PageNumber, [Gallery]), AppError>) - case fetchFrontpageItems(pageNum: Int? = nil) - case fetchFrontpageItemsDone(result: Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreFrontpageItems - case fetchMoreFrontpageItemsDone(result: Result<(PageNumber, [Gallery]), AppError>) - case fetchPopularItems - case fetchPopularItemsDone(result: Result<[Gallery], AppError>) - case fetchWatchedItems(pageNum: Int? = nil) - case fetchWatchedItemsDone(result: Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreWatchedItems - case fetchMoreWatchedItemsDone(result: Result<(PageNumber, [Gallery]), AppError>) - case fetchFavoritesItems(pageNum: Int? = nil, sortOrder: FavoritesSortOrder? = nil) - case fetchFavoritesItemsDone(carriedValue: Int, result: Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) - case fetchMoreFavoritesItems - case fetchMoreFavoritesItemsDone(carriedValue: Int, result: Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) - case fetchToplistsItems(pageNum: Int? = nil) - case fetchToplistsItemsDone(carriedValue: Int, result: Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreToplistsItems - case fetchMoreToplistsItemsDone(carriedValue: Int, result: Result<(PageNumber, [Gallery]), AppError>) - case fetchGalleryDetail(gid: String) - case fetchGalleryDetailDone(gid: String, result: Result<(GalleryDetail, GalleryState, APIKey, Greeting?), AppError>) - case fetchGalleryArchiveFunds(gid: String) - case fetchGalleryArchiveFundsDone(result: Result<((CurrentGP, CurrentCredits)), AppError>) - case fetchGalleryPreviews(gid: String, index: Int) - case fetchGalleryPreviewsDone(gid: String, pageNumber: Int, result: Result<[Int: String], AppError>) - case fetchMPVKeys(gid: String, index: Int, mpvURL: String) - case fetchMPVKeysDone(gid: String, index: Int, result: Result<(String, [Int: String]), AppError>) - case fetchThumbnails(gid: String, index: Int) - case fetchThumbnailsDone(gid: String, index: Int, result: Result<[Int: String], AppError>) - case fetchGalleryNormalContents(gid: String, index: Int, thumbnails: [Int: String]) - case fetchGalleryNormalContentsDone(gid: String, index: Int, result: Result<([Int: String], [Int: String]), AppError>) - case refetchGalleryNormalContent(gid: String, index: Int) - case refetchGalleryNormalContentDone(gid: String, index: Int, result: Result<[Int: String], AppError>) - case fetchGalleryMPVContent(gid: String, index: Int, isRefetch: Bool = false) - case fetchGalleryMPVContentDone(gid: String, index: Int, result: Result<(String, String?, ReloadToken), AppError>) - - case createEhProfile(name: String) - case verifyEhProfile - case verifyEhProfileDone(result: Result<(Int?, Bool), AppError>) - case favorGallery(gid: String, favIndex: Int) - case unfavorGallery(gid: String) - case rateGallery(gid: String, rating: Int) - case commentGallery(gid: String, content: String) - case editGalleryComment(gid: String, commentID: String, content: String) - case voteGalleryComment(gid: String, commentID: String, vote: Int) - // swiftlint:enable line_length -} diff --git a/EhPanda/DataFlow/AppCommand.swift b/EhPanda/DataFlow/AppCommand.swift deleted file mode 100644 index 5340f62a..00000000 --- a/EhPanda/DataFlow/AppCommand.swift +++ /dev/null @@ -1,832 +0,0 @@ -// -// AppCommand.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/12/26. -// - -import Kanna -import Combine -import Foundation - -protocol AppCommand { - func execute(in store: Store) -} - -struct FetchGreetingCommand: AppCommand { - func execute(in store: Store) { - let token = SubscriptionToken() - GreetingRequest().publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchGreetingDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { greeting in - store.dispatch(.fetchGreetingDone(result: .success(greeting))) - } - .seal(in: token) - } -} - -struct FetchUserInfoCommand: AppCommand { - let uid: String - - func execute(in store: Store) { - let token = SubscriptionToken() - UserInfoRequest(uid: uid).publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchUserInfoDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { user in - store.dispatch(.fetchUserInfoDone(result: .success(user))) - } - .seal(in: token) - } -} - -struct FetchFavoriteNamesCommand: AppCommand { - func execute(in store: Store) { - let token = SubscriptionToken() - FavoriteNamesRequest().publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchFavoriteNamesDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { names in - store.dispatch(.fetchFavoriteNamesDone(result: .success(names))) - } - .seal(in: token) - } -} - -struct FetchTagTranslatorCommand: AppCommand { - let language: TranslatableLanguage - let updatedDate: Date - - func execute(in store: Store) { - let token = SubscriptionToken() - TagTranslatorRequest(language: language, updatedDate: updatedDate) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchTagTranslatorDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { translator in - store.dispatch(.fetchTagTranslatorDone(result: .success(translator))) - } - .seal(in: token) - } -} - -struct FetchGalleryItemReverseCommand: AppCommand { - let gid: String - let url: String - let shouldParseGalleryURL: Bool - - func execute(in store: Store) { - let token = SubscriptionToken() - GalleryItemReverseRequest(url: url, shouldParseGalleryURL: shouldParseGalleryURL) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - store.dispatch(.fetchGalleryItemReverseDone(carriedValue: gid, result: .failure(error))) - case .finished: - store.dispatch(.fetchGalleryItemReverseDone(carriedValue: gid, result: .failure(.networkingFailed))) - } - token.unseal() - } receiveValue: { gallery in - if let gallery = gallery { - store.dispatch(.fetchGalleryItemReverseDone(carriedValue: gid, result: .success(gallery))) - } else { - store.dispatch(.fetchGalleryItemReverseDone(carriedValue: gid, result: .failure(.networkingFailed))) - } - } - .seal(in: token) - } -} - -struct FetchSearchItemsCommand: AppCommand { - let keyword: String - let filter: Filter - var pageNum: Int? - - func execute(in store: Store) { - let token = SubscriptionToken() - SearchItemsRequest(keyword: keyword, filter: filter, pageNum: pageNum) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchSearchItemsDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, galleries) in - if !galleries.isEmpty { - store.dispatch(.fetchSearchItemsDone(result: .success((pageNumber, galleries)))) - } else { - store.dispatch(.fetchSearchItemsDone(result: .failure(.notFound))) - guard pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreSearchItems(keyword: keyword)) - } - } - } - .seal(in: token) - } -} - -struct FetchMoreSearchItemsCommand: AppCommand { - let keyword: String - let filter: Filter - let lastID: String - let pageNum: Int - - func execute(in store: Store) { - let token = SubscriptionToken() - MoreSearchItemsRequest( - keyword: keyword, - filter: filter, - lastID: lastID, - pageNum: pageNum - ) - .publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchMoreSearchItemsDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, galleries) in - store.dispatch(.fetchMoreSearchItemsDone(result: .success((pageNumber, galleries)))) - - guard galleries.isEmpty, pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreSearchItems(keyword: keyword)) - } - } - .seal(in: token) - } -} - -struct FetchFrontpageItemsCommand: AppCommand { - let filter: Filter - var pageNum: Int? - - func execute(in store: Store) { - let token = SubscriptionToken() - FrontpageItemsRequest(filter: filter, pageNum: pageNum).publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchFrontpageItemsDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, galleries) in - if !galleries.isEmpty { - store.dispatch(.fetchFrontpageItemsDone(result: .success((pageNumber, galleries)))) - } else { - store.dispatch(.fetchFrontpageItemsDone(result: .failure(.notFound))) - guard pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreFrontpageItems) - } - } - } - .seal(in: token) - } -} - -struct FetchMoreFrontpageItemsCommand: AppCommand { - let filter: Filter - let lastID: String - let pageNum: Int - - func execute(in store: Store) { - let token = SubscriptionToken() - MoreFrontpageItemsRequest(filter: filter, lastID: lastID, pageNum: pageNum) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchMoreFrontpageItemsDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, galleries) in - store.dispatch(.fetchMoreFrontpageItemsDone(result: .success((pageNumber, galleries)))) - - guard galleries.isEmpty, pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreFrontpageItems) - } - } - .seal(in: token) - } -} - -struct FetchPopularItemsCommand: AppCommand { - let filter: Filter - var pageNum: Int? - - func execute(in store: Store) { - let token = SubscriptionToken() - PopularItemsRequest(filter: filter).publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchPopularItemsDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { galleries in - if !galleries.isEmpty { - store.dispatch(.fetchPopularItemsDone(result: .success(galleries))) - } else { - store.dispatch(.fetchPopularItemsDone(result: .failure(.notFound))) - } - } - .seal(in: token) - } -} - -struct FetchWatchedItemsCommand: AppCommand { - let filter: Filter - var pageNum: Int? - - func execute(in store: Store) { - let token = SubscriptionToken() - WatchedItemsRequest(filter: filter, pageNum: pageNum).publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchWatchedItemsDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, galleries) in - if !galleries.isEmpty { - store.dispatch(.fetchWatchedItemsDone(result: .success((pageNumber, galleries)))) - } else { - store.dispatch(.fetchWatchedItemsDone(result: .failure(.notFound))) - guard pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreWatchedItems) - } - } - } - .seal(in: token) - } -} - -struct FetchMoreWatchedItemsCommand: AppCommand { - let filter: Filter - let lastID: String - let pageNum: Int - - func execute(in store: Store) { - let token = SubscriptionToken() - MoreWatchedItemsRequest(filter: filter, lastID: lastID, pageNum: pageNum) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchMoreWatchedItemsDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, galleries) in - store.dispatch(.fetchMoreWatchedItemsDone(result: .success((pageNumber, galleries)))) - - guard galleries.isEmpty, pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreWatchedItems) - } - } - .seal(in: token) - } -} - -struct FetchFavoritesItemsCommand: AppCommand { - let favIndex: Int - var pageNum: Int? - var sortOrder: FavoritesSortOrder? - - func execute(in store: Store) { - let token = SubscriptionToken() - FavoritesItemsRequest(favIndex: favIndex, pageNum: pageNum, sortOrder: sortOrder) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchFavoritesItemsDone(carriedValue: favIndex, result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, sortOrder, galleries) in - if !galleries.isEmpty { - store.dispatch(.fetchFavoritesItemsDone( - carriedValue: favIndex, result: .success((pageNumber, sortOrder, galleries))) - ) - } else { - store.dispatch(.fetchFavoritesItemsDone(carriedValue: favIndex, result: .failure(.notFound))) - guard pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreFavoritesItems) - } - } - } - .seal(in: token) - } -} - -struct FetchMoreFavoritesItemsCommand: AppCommand { - let favIndex: Int - let lastID: String - let pageNum: Int - - func execute(in store: Store) { - let token = SubscriptionToken() - MoreFavoritesItemsRequest(favIndex: favIndex, lastID: lastID, pageNum: pageNum) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchMoreFavoritesItemsDone(carriedValue: favIndex, result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, sortOrder, galleries) in - store.dispatch(.fetchMoreFavoritesItemsDone( - carriedValue: favIndex, result: .success((pageNumber, sortOrder, galleries))) - ) - guard galleries.isEmpty, pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreFavoritesItems) - } - } - .seal(in: token) - } -} - -struct FetchToplistsItemsCommand: AppCommand { - let topIndex: Int - let catIndex: Int - var pageNum: Int? - - func execute(in store: Store) { - let token = SubscriptionToken() - ToplistsItemsRequest(catIndex: catIndex, pageNum: pageNum) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchToplistsItemsDone(carriedValue: topIndex, result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, galleries) in - if !galleries.isEmpty { - store.dispatch(.fetchToplistsItemsDone( - carriedValue: topIndex, result: .success((pageNumber, galleries))) - ) - } else { - store.dispatch(.fetchToplistsItemsDone(carriedValue: topIndex, result: .failure(.notFound))) - guard pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreToplistsItems) - } - } - } - .seal(in: token) - } -} - -struct FetchMoreToplistsItemsCommand: AppCommand { - let topIndex: Int - let catIndex: Int - let pageNum: Int - - func execute(in store: Store) { - let token = SubscriptionToken() - MoreToplistsItemsRequest(catIndex: catIndex, pageNum: pageNum) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchMoreToplistsItemsDone(carriedValue: topIndex, result: .failure(error))) - } - token.unseal() - } receiveValue: { (pageNumber, galleries) in - store.dispatch(.fetchMoreToplistsItemsDone( - carriedValue: topIndex, result: .success((pageNumber, galleries))) - ) - guard galleries.isEmpty, pageNumber.current < pageNumber.maximum else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.dispatch(.fetchMoreToplistsItems) - } - } - .seal(in: token) - } -} - -struct FetchGalleryDetailCommand: AppCommand { - let gid: String - let galleryURL: String - - func execute(in store: Store) { - let token = SubscriptionToken() - GalleryDetailRequest(gid: gid, galleryURL: galleryURL) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchGalleryDetailDone(gid: gid, result: .failure(error))) - } - token.unseal() - } receiveValue: { (detail, state, apiKey, greeting) in - store.dispatch(.fetchGalleryDetailDone(gid: gid, result: .success((detail, state, apiKey, greeting)))) - } - .seal(in: token) - } -} - -struct FetchGalleryArchiveFundsCommand: AppCommand { - let gid: String - let galleryURL: String - - func execute(in store: Store) { - let sToken = SubscriptionToken() - GalleryArchiveFundsRequest(gid: gid, galleryURL: galleryURL) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchGalleryArchiveFundsDone(result: .failure(error))) - } - sToken.unseal() - } receiveValue: { funds in - if let funds = funds { - store.dispatch(.fetchGalleryArchiveFundsDone(result: .success(funds))) - } else { - store.dispatch(.fetchGalleryArchiveFundsDone(result: .failure(.networkingFailed))) - } - } - .seal(in: sToken) - } -} - -struct FetchGalleryPreviewsCommand: AppCommand { - let gid: String - let url: String - let pageNumber: Int - - func execute(in store: Store) { - let token = SubscriptionToken() - GalleryPreviewsRequest(url: url).publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchGalleryPreviewsDone( - gid: gid, pageNumber: pageNumber, result: .failure(error) - )) - } - token.unseal() - } receiveValue: { previews in - store.dispatch(.fetchGalleryPreviewsDone( - gid: gid, pageNumber: pageNumber, result: .success(previews) - )) - } - .seal(in: token) - } -} - -struct FetchMPVKeysCommand: AppCommand { - let gid: String - let mpvURL: String - let pageCount: Int - let index: Int - - func execute(in store: Store) { - let token = SubscriptionToken() - MPVKeysRequest(mpvURL: mpvURL).publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchMPVKeysDone(gid: gid, index: index, result: .failure(error))) - } - token.unseal() - } receiveValue: { (mpvKey, imgKeys) in - if imgKeys.keys.count == pageCount { - store.dispatch(.fetchMPVKeysDone(gid: gid, index: index, result: .success((mpvKey, imgKeys)))) - } else { - store.dispatch(.fetchMPVKeysDone(gid: gid, index: index, result: .failure(.parseFailed))) - } - } - .seal(in: token) - } -} - -struct FetchThumbnailsCommand: AppCommand { - let gid: String - let index: Int - let url: String - - func execute(in store: Store) { - let token = SubscriptionToken() - ThumbnailsRequest(url: url).publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchThumbnailsDone(gid: gid, index: index, result: .failure(error))) - } - token.unseal() - } receiveValue: { urls in - if !urls.isEmpty { - store.dispatch(.fetchThumbnailsDone(gid: gid, index: index, result: .success(urls))) - } else { - store.dispatch(.fetchThumbnailsDone(gid: gid, index: index, result: .failure(.networkingFailed))) - } - } - .seal(in: token) - } -} - -struct FetchGalleryNormalContentsCommand: AppCommand { - let gid: String - let index: Int - let thumbnails: [Int: String] - - func execute(in store: Store) { - let token = SubscriptionToken() - GalleryNormalContentsRequest(thumbnails: thumbnails) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchGalleryNormalContentsDone(gid: gid, index: index, result: .failure(error))) - } - token.unseal() - } receiveValue: { contents, originalContents in - if !contents.isEmpty { - store.dispatch(.fetchGalleryNormalContentsDone( - gid: gid, index: index, result: .success((contents, originalContents)) - )) - } else { - store.dispatch(.fetchGalleryNormalContentsDone( - gid: gid, index: index, result: .failure(.networkingFailed)) - ) - } - } - .seal(in: token) - } -} - -struct RefetchGalleryNormalContentCommand: AppCommand { - let gid: String - let index: Int - let galleryURL: String - let thumbnailURL: String? - let storedImageURL: String - let bypassesSNIFiltering: Bool - - func execute(in store: Store) { - let token = SubscriptionToken() - GalleryNormalContentRefetchRequest( - index: index, galleryURL: galleryURL, - thumbnailURL: thumbnailURL, - storedImageURL: storedImageURL, - bypassesSNIFiltering: bypassesSNIFiltering - ) - .publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.refetchGalleryNormalContentDone(gid: gid, index: index, result: .failure(error))) - } - token.unseal() - } receiveValue: { content in - if !content.isEmpty { - store.dispatch(.refetchGalleryNormalContentDone(gid: gid, index: index, result: .success(content))) - } else { - store.dispatch(.refetchGalleryNormalContentDone( - gid: gid, index: index, result: .failure(.networkingFailed)) - ) - } - } - .seal(in: token) - } -} - -struct FetchGalleryMPVContentCommand: AppCommand { - let gid: Int - let index: Int - let mpvKey: String - let imgKey: String - let reloadToken: ReloadToken? - - func execute(in store: Store) { - let token = SubscriptionToken() - GalleryMPVContentRequest( - gid: gid, index: index, mpvKey: mpvKey, imgKey: imgKey, reloadToken: reloadToken - ) - .publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.fetchGalleryMPVContentDone( - gid: "\(gid)", index: index, result: .failure(error) - )) - } - token.unseal() - } receiveValue: { content in - store.dispatch(.fetchGalleryMPVContentDone( - gid: "\(gid)", index: index, result: .success(content) - )) - } - .seal(in: token) - } -} - -struct FetchIgneousCommand: AppCommand { - func execute(in store: Store) { - let token = SubscriptionToken() - IgneousRequest().publisher - .receive(on: DispatchQueue.main) - .sink { _ in - token.unseal() - } receiveValue: { _ in } - .seal(in: token) - } -} - -struct VerifyEhProfileCommand: AppCommand { - func execute(in store: Store) { - let token = SubscriptionToken() - VerifyEhProfileRequest().publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - store.dispatch(.verifyEhProfileDone(result: .failure(error))) - } - token.unseal() - } receiveValue: { - store.dispatch(.verifyEhProfileDone(result: .success($0))) - } - .seal(in: token) - } -} - -struct CreateEhProfileCommand: AppCommand { - let name: String - - func execute(in store: Store) { - let token = SubscriptionToken() - EhProfileRequest(action: .create, name: name) - .publisher.receive(on: DispatchQueue.main) - .sink { _ in - token.unseal() - } receiveValue: { _ in } - .seal(in: token) - } -} - -struct AddFavoriteCommand: AppCommand { - let gid: String - let token: String - let favIndex: Int - - func execute(in store: Store) { - let sToken = SubscriptionToken() - AddFavoriteRequest(gid: gid, token: token, favIndex: favIndex) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .finished = completion { - store.dispatch(.fetchGalleryDetail(gid: gid)) - } - sToken.unseal() - } receiveValue: { _ in } - .seal(in: sToken) - } -} - -struct DeleteFavoriteCommand: AppCommand { - let gid: String - - func execute(in store: Store) { - let sToken = SubscriptionToken() - DeleteFavoriteRequest(gid: gid).publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .finished = completion { - store.dispatch(.fetchGalleryDetail(gid: gid)) - } - sToken.unseal() - } receiveValue: { _ in } - .seal(in: sToken) - } -} - -struct RateCommand: AppCommand { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let rating: Int - - func execute(in store: Store) { - let sToken = SubscriptionToken() - RateRequest( - apiuid: apiuid, - apikey: apikey, - gid: gid, - token: token, - rating: rating - ) - .publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .finished = completion { - store.dispatch(.fetchGalleryDetail(gid: String(gid))) - } - sToken.unseal() - } receiveValue: { _ in } - .seal(in: sToken) - } -} - -struct CommentCommand: AppCommand { - let gid: String - let content: String - let galleryURL: String - - func execute(in store: Store) { - let token = SubscriptionToken() - CommentRequest(content: content, galleryURL: galleryURL) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .finished = completion { - store.dispatch(.fetchGalleryDetail(gid: gid)) - } - token.unseal() - } receiveValue: { _ in } - .seal(in: token) - } -} - -struct EditCommentCommand: AppCommand { - let gid: String - let commentID: String - let content: String - let galleryURL: String - - func execute(in store: Store) { - let token = SubscriptionToken() - EditCommentRequest( - commentID: commentID, - content: content, - galleryURL: galleryURL - ) - .publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .finished = completion { - store.dispatch(.fetchGalleryDetail(gid: gid)) - } - token.unseal() - } receiveValue: { _ in } - .seal(in: token) - } -} - -struct VoteCommentCommand: AppCommand { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let commentID: Int - let commentVote: Int - - func execute(in store: Store) { - let sToken = SubscriptionToken() - VoteCommentRequest( - apiuid: apiuid, - apikey: apikey, - gid: gid, - token: token, - commentID: commentID, - commentVote: commentVote - ) - .publisher - .receive(on: DispatchQueue.main) - .sink { completion in - if case .finished = completion { - store.dispatch(.fetchGalleryDetail(gid: String(gid))) - } - sToken.unseal() - } receiveValue: { _ in } - .seal(in: sToken) - } -} - -final class SubscriptionToken { - var cancellable: AnyCancellable? - func unseal() { cancellable = nil } -} - -extension AnyCancellable { - func seal(in token: SubscriptionToken) { - token.cancellable = self - } -} diff --git a/EhPanda/DataFlow/AppDelegateStore.swift b/EhPanda/DataFlow/AppDelegateStore.swift new file mode 100644 index 00000000..3967ac2d --- /dev/null +++ b/EhPanda/DataFlow/AppDelegateStore.swift @@ -0,0 +1,99 @@ +// +// AppDelegateStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/25. +// + +import SwiftUI +import SwiftyBeaver +import ComposableArchitecture + +// MARK: AppDelegate +class AppDelegate: UIResponder, UIApplicationDelegate { + let store = Store( + initialState: AppState(), + reducer: appReducer, + environment: AppEnvironment( + dfClient: .live, + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + loggerClient: .live, + hapticClient: .live, + libraryClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + userDefaultsClient: .live, + uiApplicationClient: .live, + authorizationClient: .live + ) + ) + lazy var viewStore = ViewStore(store) + + static var orientationMask: UIInterfaceOrientationMask = + DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] + + func application( + _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? + ) -> UIInterfaceOrientationMask { AppDelegate.orientationMask } + + func application( + _ application: UIApplication, didFinishLaunchingWithOptions + launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + viewStore.send(.appDelegate(.onLaunchFinish)) + return true + } +} + +struct AppDelegateState: Equatable { + var migrationState = MigrationState() +} + +enum AppDelegateAction { + case onLaunchFinish + + case migration(MigrationAction) +} + +struct AppDelegateEnvironment { + let dfClient: DFClient + let libraryClient: LibraryClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient +} + +let appDelegateReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .onLaunchFinish: + let bypassesSNIFiltering = state.settingState.setting.bypassesSNIFiltering + state.appLockState.becomeInactiveDate = .distantPast + return .merge( + environment.libraryClient.initializeLogger().fireAndForget(), + environment.libraryClient.initializeWebImage().fireAndForget(), + environment.dfClient.setActive(bypassesSNIFiltering).fireAndForget(), + environment.cookiesClient.removeYay().fireAndForget(), + environment.cookiesClient.ignoreOffensive().fireAndForget(), + environment.cookiesClient.fulfillAnotherHostField().fireAndForget(), + .init(value: .migration(.prepareDatabase)) + ) + + case .migration: + return .none + } + }, + migrationReducer.pullback( + state: \.appDelegateState.migrationState, + action: /AppDelegateAction.migration, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ) +) diff --git a/EhPanda/DataFlow/AppError.swift b/EhPanda/DataFlow/AppError.swift deleted file mode 100644 index e353a64e..00000000 --- a/EhPanda/DataFlow/AppError.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// AppError.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/12/26. -// - -import Foundation - -enum AppError: Error, Identifiable, Equatable { - var id: String { localizedDescription } - - case ipBanned(interval: BanInterval) - case copyrightClaim(owner: String) - case expunged(reason: String) - case networkingFailed - case parseFailed - case noUpdates - case notFound - case unknown -} - -extension AppError: LocalizedError { - var localizedDescription: String { - switch self { - case .ipBanned: - return "IP Banned" - case .copyrightClaim: - return "Copyright Claim" - case .expunged: - return "Gallery Expunged" - case .networkingFailed: - return "Network Error" - case .parseFailed: - return "Parse Error" - case .noUpdates: - return "No updates available" - case .notFound: - return "Not found" - case .unknown: - return "Unknown Error" - } - } - var symbolName: String { - switch self { - case .ipBanned: - return "network.badge.shield.half.filled" - case .copyrightClaim, .expunged: - return "trash.circle.fill" - case .networkingFailed: - return "wifi.exclamationmark" - case .parseFailed: - return "rectangle.and.text.magnifyingglass" - case .noUpdates: - return "" - case .notFound, .unknown: - return "questionmark.circle.fill" - } - } - // swiftlint:disable line_length - var alertText: String { - let tryLater = "Please try again later." - - switch self { - case .ipBanned(let interval): - return "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software.".localized + " " + interval.description - case .copyrightClaim(let owner): - return "This gallery is unavailable due to a copyright claim by PLACEHOLDER. Sorry about that." - .localized.replacingOccurrences(of: "PLACEHOLDER", with: owner) - case .expunged(let reason): - return reason.localized - case .networkingFailed: - return ["A network error occurred.", tryLater] - .map(\.localized).joined(separator: "\n") - case .parseFailed: - return ["A parsing error occurred.", tryLater] - .map(\.localized).joined(separator: "\n") - case .noUpdates: - return "" - case .notFound: - return "There seems to be nothing here." - case .unknown: - return ["An unknown error occurred.", tryLater] - .map(\.localized).joined(separator: "\n") - } - } - // swiftlint:enable line_length -} - -enum BanInterval: Equatable { - case days(_: Int, hours: Int?) - case hours(_: Int, minutes: Int?) - case minutes(_: Int, seconds: Int?) - case unrecognized(content: String) -} - -extension BanInterval { - var description: String { - let base = "The ban expires in PLACEHOLDER.".localized - var placeholder = "" - - switch self { - case .days(let days, let hours): - var params = [String(days), "BAN_INTERVAL_DAYS"] - if let hours = hours { - params += [ - "BAN_INTERVAL_AND", String(hours), "BAN_INTERVAL_HOURS" - ] - } - placeholder = params.map{ $0.localized }.joined(separator: "") - case .hours(let hours, let minutes): - var params = [String(hours), "BAN_INTERVAL_HOURS"] - if let minutes = minutes { - params += [ - "BAN_INTERVAL_AND", String(minutes), "BAN_INTERVAL_MINUTES" - ] - } - placeholder = params.map{ $0.localized }.joined(separator: "") - case .minutes(let minutes, let seconds): - var params = [String(minutes), "BAN_INTERVAL_MINUTES"] - if let seconds = seconds { - params += [ - "BAN_INTERVAL_AND", String(seconds), "BAN_INTERVAL_SECONDS" - ] - } - placeholder = params.map{ $0.localized }.joined(separator: "") - case .unrecognized(let content): - placeholder = content - } - - return base.replacingOccurrences(of: "PLACEHOLDER", with: placeholder) - } -} diff --git a/EhPanda/DataFlow/AppLockStore.swift b/EhPanda/DataFlow/AppLockStore.swift new file mode 100644 index 00000000..6b989bc5 --- /dev/null +++ b/EhPanda/DataFlow/AppLockStore.swift @@ -0,0 +1,65 @@ +// +// AppLockStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/05. +// + +import SwiftUI +import ComposableArchitecture + +struct AppLockState: Equatable { + @BindableState var blurRadius: Double = 0 + var becomeInactiveDate: Date? + var isAppLocked = false + + // Setting `blurRadius` to zero causes the NavigationBar to collapse + mutating func setBlurRadius(_ radius: Double) { + blurRadius = max(0.00001, radius) + } +} + +enum AppLockAction { + case onBecomeActive(Int, Double) + case onBecomeInactive(Double) + case authorize + case authorizeDone(Bool) +} + +struct AppLockEnvironment { + let authorizationClient: AuthorizationClient +} + +let appLockReducer = Reducer { state, action, environment in + switch action { + case .onBecomeActive(let threshold, let blurRadius): + if let date = state.becomeInactiveDate, threshold >= 0, + Date().timeIntervalSince(date) > Double(threshold) + { + state.setBlurRadius(blurRadius) + state.isAppLocked = true + return .init(value: .authorize) + } else { + state.setBlurRadius(0) + } + return .none + + case .onBecomeInactive(let blurRadius): + state.setBlurRadius(blurRadius) + state.becomeInactiveDate = Date() + return .none + + case .authorize: + return environment.authorizationClient + .localAuthroize(R.string.localizable.localAuthorizationReason()) + .map(AppLockAction.authorizeDone) + + case .authorizeDone(let isSucceeded): + if isSucceeded { + state.setBlurRadius(0) + state.isAppLocked = false + state.becomeInactiveDate = nil + } + return .none + } +} diff --git a/EhPanda/DataFlow/AppRouteStore.swift b/EhPanda/DataFlow/AppRouteStore.swift new file mode 100644 index 00000000..c5e1acb8 --- /dev/null +++ b/EhPanda/DataFlow/AppRouteStore.swift @@ -0,0 +1,203 @@ +// +// AppRouteStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import SwiftUI +import TTProgressHUD +import ComposableArchitecture + +struct AppRouteState: Equatable { + enum Route: Equatable, Hashable { + case hud + case setting + case detail(String) + case newDawn(Greeting) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + var hudConfig: TTProgressHUDConfig = .loading + + @Heap var detailState: DetailState! +} + +enum AppRouteAction: BindableAction { + case binding(BindingAction) + case setNavigation(AppRouteState.Route?) + case setHUDConfig(TTProgressHUDConfig) + case clearSubStates + + case detectClipboardURL + case handleDeepLink(URL) + case handleGalleryLink(URL) + + case updateReadingProgress(String, Int) + + case fetchGallery(URL, Bool) + case fetchGalleryDone(URL, Result) + case fetchGreetingDone(Result) + + case detail(DetailAction) +} + +struct AppRouteEnvironment { + let dfClient: DFClient + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let loggerClient: LoggerClient + let hapticClient: HapticClient + let libraryClient: LibraryClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let userDefaultsClient: UserDefaultsClient + let uiApplicationClient: UIApplicationClient + let authorizationClient: AuthorizationClient +} + +let appRouteReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .setHUDConfig(let config): + state.hudConfig = config + return .none + + case .clearSubStates: + state.detailState = .init() + return .init(value: .detail(.teardown)) + + case .detectClipboardURL: + let currentChangeCount = environment.clipboardClient.changeCount() + guard currentChangeCount != environment.userDefaultsClient + .getValue(.clipboardChangeCount) else { return .none } + var effects: [Effect] = [ + environment.userDefaultsClient + .setValue(currentChangeCount, .clipboardChangeCount).fireAndForget() + ] + if let url = environment.clipboardClient.url() { + effects.append(.init(value: .handleDeepLink(url))) + } + return .merge(effects) + + case .handleDeepLink(let url): + var url = environment.urlClient.resolveAppSchemeURL(url) ?? url + guard environment.urlClient.checkIfHandleable(url) else { return .none } + var delay = 0 + if case .detail = state.route { + delay = 1000 + state.route = nil + state.detailState = .init() + } + let (isGalleryImageURL, _, _) = environment.urlClient.analyzeURL(url) + let gid = environment.urlClient.parseGalleryID(url) + guard environment.databaseClient.fetchGallery(gid: gid) == nil else { + return .init(value: .handleGalleryLink(url)) + .delay(for: .milliseconds(delay + 250), scheduler: DispatchQueue.main).eraseToEffect() + } + return .init(value: .fetchGallery(url, isGalleryImageURL)) + .delay(for: .milliseconds(delay), scheduler: DispatchQueue.main).eraseToEffect() + + case .handleGalleryLink(let url): + let (_, pageIndex, commentID) = environment.urlClient.analyzeURL(url) + let gid = environment.urlClient.parseGalleryID(url) + var effects = [Effect]() + state.detailState = .init() + effects.append(.init(value: .detail(.fetchDatabaseInfos(gid)))) + if let pageIndex = pageIndex { + effects.append(.init(value: .updateReadingProgress(gid, pageIndex))) + effects.append( + .init(value: .detail(.setNavigation(.reading))) + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + ) + } else if let commentID = commentID { + state.detailState.commentsState?.scrollCommentID = commentID + effects.append( + .init(value: .detail(.setNavigation(.comments(url)))) + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + ) + } + effects.append(.init(value: .setNavigation(.detail(gid)))) + return .merge(effects) + + case .updateReadingProgress(let gid, let progress): + guard !gid.isEmpty else { return .none } + return environment.databaseClient + .updateReadingProgress(gid: gid, progress: progress).fireAndForget() + + case .fetchGallery(let url, let isGalleryImageURL): + state.route = .hud + return GalleryReverseRequest(url: url, isGalleryImageURL: isGalleryImageURL) + .effect.map({ AppRouteAction.fetchGalleryDone(url, $0) }) + + case .fetchGalleryDone(let url, let result): + state.route = nil + switch result { + case .success(let gallery): + return .merge( + environment.databaseClient.cacheGalleries([gallery]).fireAndForget(), + .init(value: .handleGalleryLink(url)) + ) + case .failure: + return .init(value: .setHUDConfig(.error)) + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + } + + case .fetchGreetingDone(let result): + if case .success(let greeting) = result, !greeting.gainedNothing { + return .init(value: .setNavigation(.newDawn(greeting))) + } + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /AppRouteState.Route.newDawn, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /AppRouteState.Route.detail, + hapticClient: \.hapticClient + ) + .binding(), + detailReducer.pullback( + state: \.detailState, + action: /AppRouteAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/DataFlow/AppState.swift b/EhPanda/DataFlow/AppState.swift deleted file mode 100644 index 8c0cf06e..00000000 --- a/EhPanda/DataFlow/AppState.swift +++ /dev/null @@ -1,287 +0,0 @@ -// -// AppState.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/12/26. -// - -import SwiftUI -import Foundation - -struct AppState { - var environment = Environment() - var settings = Settings() - var homeInfo = HomeInfo() - var detailInfo = DetailInfo() - var contentInfo = ContentInfo() -} - -extension AppState { - // MARK: Environment - struct Environment { - var isPreview = false - var isAppUnlocked = true - var blurRadius: CGFloat = 0 - var viewControllersCount = 1 - var slideMenuClosed = true - var navigationBarHidden = false - var favoritesIndex = -1 - var favoritesSortOrder: FavoritesSortOrder? - var toplistsType: ToplistsType = .allTime - var homeListType: HomeListType = .frontpage - var homeViewSheetState: HomeViewSheetState? - var settingViewSheetState: SettingViewSheetState? - var detailViewSheetState: DetailViewSheetState? - var commentViewSheetState: CommentViewSheetState? - - var galleryItemReverseID: String? - var galleryItemReverseLoading = false - var galleryItemReverseLoadFailed = false - } - - // MARK: Settings - struct Settings { - var userInfoLoading = false - var favoriteNamesLoading = false - var greetingLoading = false - - var appEnv: AppEnv { - PersistenceController.fetchAppEnvNonNil() - } - - @AppEnvStorage(type: User.self) - var user: User - - @AppEnvStorage(type: Filter.self, key: "searchFilter") - var searchFilter: Filter - - @AppEnvStorage(type: Filter.self, key: "globalFilter") - var globalFilter: Filter - - @AppEnvStorage(type: Setting.self) - var setting: Setting - - @AppEnvStorage(type: TagTranslator.self, key: "tagTranslator") - var tagTranslator: TagTranslator - - mutating func update(user: User) { - if let displayName = user.displayName { - self.user.displayName = displayName - } - if let avatarURL = user.avatarURL { - self.user.avatarURL = avatarURL - } - if let currentGP = user.currentGP, - let currentCredits = user.currentCredits - { - self.user.currentGP = currentGP - self.user.currentCredits = currentCredits - } - } - - mutating func insert(greeting: Greeting) { - guard let currDate = greeting.updateTime - else { return } - - if let prevGreeting = user.greeting, - let prevDate = prevGreeting.updateTime, - prevDate < currDate - { - user.greeting = greeting - } else if user.greeting == nil { - user.greeting = greeting - } - } - } -} - -extension AppState { - // MARK: HomeInfo - struct HomeInfo { - var searchItems = [Gallery]() - var searchLoading = false - var searchLoadError: AppError? - var searchPageNumber = PageNumber() - var moreSearchLoading = false - var moreSearchLoadFailed = false - - var frontpageItems = [Gallery]() - var frontpageLoading = false - var frontpageLoadError: AppError? - var frontpagePageNumber = PageNumber() - var moreFrontpageLoading = false - var moreFrontpageLoadFailed = false - - var popularItems = [Gallery]() - var popularLoading = false - var popularLoadError: AppError? - - var watchedItems = [Gallery]() - var watchedLoading = false - var watchedLoadError: AppError? - var watchedPageNumber = PageNumber() - var moreWatchedLoading = false - var moreWatchedLoadFailed = false - - var favoritesItems = [Int: [Gallery]]() - var favoritesLoading = [Int: Bool]() - var favoritesLoadErrors = [Int: AppError]() - var favoritesPageNumbers = [Int: PageNumber]() - var moreFavoritesLoading = [Int: Bool]() - var moreFavoritesLoadFailed = [Int: Bool]() - - var toplistsItems = [Int: [Gallery]]() - var toplistsLoading = [Int: Bool]() - var toplistsLoadErrors = [Int: AppError]() - var toplistsPageNumbers = [Int: PageNumber]() - var moreToplistsLoading = [Int: Bool]() - var moreToplistsLoadFailed = [Int: Bool]() - - @AppEnvStorage(type: [String].self, key: "historyKeywords") - var historyKeywords: [String] - @AppEnvStorage(type: [QuickSearchWord].self, key: "quickSearchWords") - var quickSearchWords: [QuickSearchWord] - - func insertGalleries(stored: inout [Gallery], new: [Gallery]) { - new.forEach { gallery in - if !stored.contains(gallery) { - stored.append(gallery) - } - } - } - mutating func insertSearchItems(galleries: [Gallery]) { - insertGalleries(stored: &searchItems, new: galleries) - } - mutating func insertFrontpageItems(galleries: [Gallery]) { - insertGalleries(stored: &frontpageItems, new: galleries) - } - mutating func insertWatchedItems(galleries: [Gallery]) { - insertGalleries(stored: &watchedItems, new: galleries) - } - mutating func insertFavoritesItems(favIndex: Int, galleries: [Gallery]) { - galleries.forEach { gallery in - if favoritesItems[favIndex]?.contains(gallery) == false { - favoritesItems[favIndex]?.append(gallery) - } - } - } - mutating func insertToplistsItems(topIndex: Int, galleries: [Gallery]) { - galleries.forEach { gallery in - if toplistsItems[topIndex]?.contains(gallery) == false { - toplistsItems[topIndex]?.append(gallery) - } - } - } - mutating func appendHistoryKeywords(texts: [String]) { - guard !texts.isEmpty else { return } - var historyKeywords = historyKeywords - - texts.forEach { text in - guard !text.isEmpty else { return } - if let index = historyKeywords.firstIndex(of: text) { - if historyKeywords.last != text { - historyKeywords.remove(at: index) - historyKeywords.append(text) - } - } else { - historyKeywords.append(text) - let overflow = historyKeywords.count - 15 - if overflow > 0 { - historyKeywords = Array( - historyKeywords.dropFirst(overflow) - ) - } - } - } - self.historyKeywords = historyKeywords - } - mutating func removeHistoryKeyword(text: String) { - historyKeywords = historyKeywords.filter { $0 != text } - } - mutating func appendQuickSearchWord() { - quickSearchWords.append(QuickSearchWord(content: "")) - } - mutating func deleteQuickSearchWords(offsets: IndexSet) { - quickSearchWords.remove(atOffsets: offsets) - } - mutating func moveQuickSearchWords(source: IndexSet, destination: Int) { - quickSearchWords.move(fromOffsets: source, toOffset: destination) - } - mutating func modifyQuickSearchWord(newWord: QuickSearchWord) { - if let index = quickSearchWords.firstIndex( - where: { word in word.id == newWord.id } - ) { quickSearchWords[index] = newWord } - } - } - - // MARK: DetailInfo - struct DetailInfo { - var detailLoading = [String: Bool]() - var detailLoadErrors = [String: AppError]() - var archiveFundsLoading = false - var previews = [String: [Int: String]]() - var previewsLoading = [String: [Int: Bool]]() - var previewConfig = PreviewConfig.normal(rows: 4) - - var pendingJumpPageIndices = [String: Int]() - var pendingJumpCommentIDs = [String: String]() - - mutating func fulfillPreviews(gid: String) { - let galleryState = PersistenceController - .fetchGalleryStateNonNil(gid: gid) - previews[gid] = galleryState.previews - } - - mutating func update(gid: String, previews: [Int: String]) { - guard !previews.isEmpty else { return } - - if self.previews[gid] == nil { - self.previews[gid] = [:] - } - self.previews[gid] = self.previews[gid]?.merging( - previews, uniquingKeysWith: - { stored, _ in stored } - ) - } - } - - // MARK: ContentInfo - struct ContentInfo { - var thumbnails = [String: [Int: String]]() - var mpvKeys = [String: String]() - var mpvImageKeys = [String: [Int: String]]() - var mpvReloadTokens = [String: [Int: ReloadToken]]() - var contents = [String: [Int: String]]() - var originalContents = [String: [Int: String]]() - var contentsLoading = [String: [Int: Bool]]() - var contentsLoadErrors = [String: [Int: AppError]]() - - mutating func fulfillContents(gid: String) { - let galleryState = PersistenceController.fetchGalleryStateNonNil(gid: gid) - contents[gid] = galleryState.contents - originalContents[gid] = galleryState.originalContents - thumbnails[gid] = galleryState.thumbnails - } - - func update( - gid: String, stored: inout [String: [Int: T]], - new: [Int: T], replaceExisting: Bool = true - ) { - guard !new.isEmpty else { return } - - if stored[gid] == nil { - stored[gid] = [:] - } - stored[gid] = stored[gid]?.merging( - new, uniquingKeysWith: { stored, new in replaceExisting ? new : stored } - ) - } - mutating func update(gid: String, thumbnails: [Int: String]) { - update(gid: gid, stored: &self.thumbnails, new: thumbnails) - } - mutating func update(gid: String, contents: [Int: String], originalContents: [Int: String]) { - update(gid: gid, stored: &self.contents, new: contents) - update(gid: gid, stored: &self.originalContents, new: originalContents) - } - } -} diff --git a/EhPanda/DataFlow/AppStore.swift b/EhPanda/DataFlow/AppStore.swift new file mode 100644 index 00000000..6dbdd5e5 --- /dev/null +++ b/EhPanda/DataFlow/AppStore.swift @@ -0,0 +1,310 @@ +// +// AppStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/25. +// + +import SwiftUI +import ComposableArchitecture + +struct AppState: Equatable { + var appDelegateState = AppDelegateState() + var appRouteState = AppRouteState() + var appLockState = AppLockState() + var tabBarState = TabBarState() + var homeState = HomeState() + var favoritesState = FavoritesState() + var searchRootState = SearchRootState() + var settingState = SettingState() +} + +enum AppAction: BindableAction { + case binding(BindingAction) + case onScenePhaseChange(ScenePhase) + + case appDelegate(AppDelegateAction) + case appRoute(AppRouteAction) + case appLock(AppLockAction) + + case tabBar(TabBarAction) + + case home(HomeAction) + case favorites(FavoritesAction) + case searchRoot(SearchRootAction) + case setting(SettingAction) +} + +struct AnyEnvironment {} +struct AppEnvironment { + let dfClient: DFClient + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let loggerClient: LoggerClient + let hapticClient: HapticClient + let libraryClient: LibraryClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let userDefaultsClient: UserDefaultsClient + let uiApplicationClient: UIApplicationClient + let authorizationClient: AuthorizationClient +} + +let appReducerCore = Reducer { state, action, environment in + switch action { + case .binding(\.appRouteState.$route): + return state.appRouteState.route == nil ? .init(value: .appRoute(.clearSubStates)) : .none + + case .binding(\.settingState.$setting): + return .init(value: .setting(.syncSetting)) + + case .binding: + return .none + + case .onScenePhaseChange(let scenePhase): + switch scenePhase { + case .active: + let threshold = state.settingState.setting.autoLockPolicy.rawValue + let blurRadius = state.settingState.setting.backgroundBlurRadius + var effects: [Effect] = [ + .init(value: .appLock(.onBecomeActive(threshold, blurRadius))) + ] + if threshold < 0 { + effects.append(.init(value: .setting(.fetchGreeting))) + if state.settingState.setting.detectsLinksFromClipboard { + effects.append(.init(value: .appRoute(.detectClipboardURL))) + } + } + return .merge(effects) + case .inactive: + let blurRadius = state.settingState.setting.backgroundBlurRadius + return .init(value: .appLock(.onBecomeInactive(blurRadius))) + default: + break + } + return .none + + case .appDelegate(.migration(.onDatabasePreparationSuccess)): + return .init(value: .setting(.loadUserSettings)) + + case .appDelegate: + return .none + + case .appRoute(.clearSubStates): + var effects = [Effect]() + if environment.deviceClient.isPad() { + state.settingState.route = nil + effects.append(.init(value: .setting(.clearSubStates))) + } + return effects.isEmpty ? .none : .merge(effects) + + case .appRoute: + return .none + + case .appLock(.authorizeDone(let isSucceeded)): + var effects = [Effect]() + if isSucceeded { + effects.append(.init(value: .setting(.fetchGreeting))) + if state.settingState.setting.detectsLinksFromClipboard { + effects.append(.init(value: .appRoute(.detectClipboardURL))) + } + } + return effects.isEmpty ? .none : .merge(effects) + + case .appLock: + return .none + + case .tabBar(.setTabBarItemType(let type)): + var effects = [Effect]() + if type == state.tabBarState.tabBarItemType { + switch type { + case .home: + effects.append(.init(value: .home(.fetchAllGalleries))) + case .favorites: + effects.append(.init(value: .favorites(.fetchGalleries()))) + case .search: + effects.append(.init(value: .searchRoot(.fetchDatabaseInfos))) + case .setting: + break + } + if [.home, .favorites, .search].contains(type) { + effects.append(environment.hapticClient.generateFeedback(.soft).fireAndForget()) + } + } + if type == .setting && environment.deviceClient.isPad() { + effects.append(.init(value: .appRoute(.setNavigation(.setting)))) + } + return effects.isEmpty ? .none : .merge(effects) + + case .tabBar: + return .none + + case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): + var effects: [Effect] = [ + environment.hapticClient.generateFeedback(.soft).fireAndForget(), + .init(value: .tabBar(.setTabBarItemType(.setting))) + ] + effects.append(.init(value: .setting(.setNavigation(.account)))) + if !environment.cookiesClient.didLogin { + effects.append( + .init(value: .setting(.account(.setNavigation(.login)))) + .delay(for: .milliseconds(200), scheduler: DispatchQueue.main).eraseToEffect() + ) + } + return .merge(effects) + + case .home: + return .none + + case .favorites: + return .none + + case .searchRoot: + return .none + + case .setting(.fetchGreetingDone(let result)): + return .init(value: .appRoute(.fetchGreetingDone(result))) + + case .setting: + return .none + } +} +.binding() + +let appReducer = Reducer.combine( + appReducerCore, + appRouteReducer.pullback( + state: \.appRouteState, + action: /AppAction.appRoute, + environment: { + .init( + dfClient: $0.dfClient, + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + loggerClient: $0.loggerClient, + hapticClient: $0.hapticClient, + libraryClient: $0.libraryClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + userDefaultsClient: $0.userDefaultsClient, + uiApplicationClient: $0.uiApplicationClient, + authorizationClient: $0.authorizationClient + ) + } + ), + appLockReducer.pullback( + state: \.appLockState, + action: /AppAction.appLock, + environment: { + .init( + authorizationClient: $0.authorizationClient + ) + } + ), + appDelegateReducer.pullback( + state: \.self, + action: /AppAction.appDelegate, + environment: { + .init( + dfClient: $0.dfClient, + libraryClient: $0.libraryClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient + ) + } + ), + tabBarReducer.pullback( + state: \.tabBarState, + action: /AppAction.tabBar, + environment: { + .init( + deviceClient: $0.deviceClient + ) + } + ), + homeReducer.pullback( + state: \.homeState, + action: /AppAction.home, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + libraryClient: $0.libraryClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + favoritesReducer.pullback( + state: \.favoritesState, + action: /AppAction.favorites, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + searchRootReducer.pullback( + state: \.searchRootState, + action: /AppAction.searchRoot, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + settingReducer.pullback( + state: \.settingState, + action: /AppAction.setting, + environment: { + .init( + dfClient: $0.dfClient, + fileClient: $0.fileClient, + deviceClient: $0.deviceClient, + loggerClient: $0.loggerClient, + hapticClient: $0.hapticClient, + libraryClient: $0.libraryClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + userDefaultsClient: $0.userDefaultsClient, + uiApplicationClient: $0.uiApplicationClient, + authorizationClient: $0.authorizationClient + ) + } + ) +) +.logging() diff --git a/EhPanda/DataFlow/Heap.swift b/EhPanda/DataFlow/Heap.swift new file mode 100755 index 00000000..cfdb6dd9 --- /dev/null +++ b/EhPanda/DataFlow/Heap.swift @@ -0,0 +1,38 @@ +// +// Heap.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/06. +// + +private final class Reference: Equatable { + var value: T + init(_ value: T) { + self.value = value + } + static func == (lhs: Reference, rhs: Reference) -> Bool { + lhs.value == rhs.value + } +} + +@propertyWrapper struct Heap: Equatable { + private var reference: Reference + + init(_ value: T) { + reference = .init(value) + } + + var wrappedValue: T { + get { reference.value } + set { + if !isKnownUniquelyReferenced(&reference) { + reference = .init(newValue) + return + } + reference.value = newValue + } + } + var projectedValue: Heap { + self + } +} diff --git a/EhPanda/DataFlow/Store.swift b/EhPanda/DataFlow/Store.swift deleted file mode 100644 index 85996282..00000000 --- a/EhPanda/DataFlow/Store.swift +++ /dev/null @@ -1,841 +0,0 @@ -// -// Store.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/12/26. -// - -import SwiftUI -import Combine - -final class Store: ObservableObject { - @Published var appState = AppState() - static var preview: Store = { - let store = Store() - store.appState.environment.isPreview = true - return store - }() - - func dispatch(_ action: AppAction) { - #if DEBUG - guard !AppUtil.isUnitTesting else { return } - #endif - - if Thread.isMainThread { - privateDispatch(action) - } else { - DispatchQueue.main.async { [weak self] in - self?.privateDispatch(action) - } - } - } - - private func privateDispatch(_ action: AppAction) { - let description = String(describing: action) - if description.contains("error") { - Logger.error("[ACTION]: " + description) - } else { - switch action { - case .fetchGalleryPreviewsDone(let gid, let pageNumber, let result): - if case .success(let previews) = result { - Logger.verbose( - "[ACTION]: fetchGalleryPreviewsDone(" - + "gid: \(gid), pageNumber: \(pageNumber), " - + "previews: \(previews.count))" - ) - } - case .fetchThumbnailsDone(let gid, let index, let result): - if case .success(let contents) = result { - Logger.verbose( - "[ACTION]: fetchThumbnailsDone(" - + "gid: \(gid), index: \(index), " - + "contents: \(contents.count))" - ) - } - case .fetchGalleryNormalContents(let gid, let index, let thumbnails): - Logger.verbose( - "[ACTION]: fetchGalleryNormalContents(" - + "gid: \(gid), index: \(index), " - + "thumbnails: \(thumbnails.count))" - ) - case .fetchGalleryNormalContentsDone(let gid, let index, let result): - if case .success(let (contents, originalContents)) = result { - Logger.verbose( - "[ACTION]: fetchGalleryNormalContentsDone(" - + "gid: \(gid), index: \(index), " - + "contents: \(contents.count), " - + "originalContents: \(originalContents.count))" - ) - } - case .fetchMPVKeysDone(let gid, let index, let result): - if case .success(let (mpvKey, imgKeys)) = result { - Logger.verbose( - "[ACTION]: fetchMPVKeysDone(" - + "gid: \(gid), index: \(index), " - + "mpvKey: \(mpvKey), imgKeys: \(imgKeys.count))" - ) - } - default: - Logger.verbose("[ACTION]: " + description) - } - } - let (state, command) = reduce(state: appState, action: action) - appState = state - - guard let command = command else { return } - Logger.verbose("[COMMAND]: \(command)") - command.execute(in: self) - } - - func reduce(state: AppState, action: AppAction) -> (AppState, AppCommand?) { - var appState = state - var appCommand: AppCommand? - - switch action { - // MARK: App Ops - case .resetUser: - appState.settings.user = User() - case .resetHomeInfo: - appState.homeInfo = AppState.HomeInfo() - dispatch(.setHomeListType(.frontpage)) - dispatch(.fetchFrontpageItems(pageNum: nil)) - case .resetFilter(let range): - switch range { - case .search: - appState.settings.searchFilter = Filter() - case .global: - appState.settings.globalFilter = Filter() - } - case .doFinishLoginTasks: - CookiesUtil.removeYay() - dispatch(.verifyEhProfile) - dispatch(.fetchUserInfo) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.dispatch(.resetHomeInfo) - } - case .setReadingProgress(let gid, let tag): - PersistenceController.update(gid: gid, readingProgress: tag) - case .setAppIconType(let iconType): - appState.settings.setting.appIconType = iconType - case .appendHistoryKeywords(let texts): - appState.homeInfo.appendHistoryKeywords(texts: texts) - case .removeHistoryKeyword(let text): - appState.homeInfo.removeHistoryKeyword(text: text) - case .clearHistoryKeywords: - appState.homeInfo.historyKeywords = [] - case .setSetting(let setting): - appState.settings.setting = setting - case .setViewControllersCount: - appState.environment.viewControllersCount = DeviceUtil.viewControllersCount - case .setGalleryCommentJumpID(let gid): - appState.environment.galleryItemReverseID = gid - case .setSlideMenuClosed(let closed): - appState.environment.slideMenuClosed = closed - case .fulfillGalleryPreviews(let gid): - appState.detailInfo.fulfillPreviews(gid: gid) - case .fulfillGalleryContents(let gid): - appState.contentInfo.fulfillContents(gid: gid) - case .setPendingJumpInfos(let gid, let pageIndex, let commentID): - appState.detailInfo.pendingJumpPageIndices[gid] = pageIndex - appState.detailInfo.pendingJumpCommentIDs[gid] = commentID - case .appendQuickSearchWord: - appState.homeInfo.appendQuickSearchWord() - case .deleteQuickSearchWord(let offsets): - appState.homeInfo.deleteQuickSearchWords(offsets: offsets) - case .modifyQuickSearchWord(let newWord): - appState.homeInfo.modifyQuickSearchWord(newWord: newWord) - case .moveQuickSearchWord(let source, let destination): - appState.homeInfo.moveQuickSearchWords(source: source, destination: destination) - - // MARK: App Env - case .setAppLock(let activated): - appState.environment.isAppUnlocked = !activated - case .setBlurEffect(let activated): - withAnimation(.linear(duration: 0.1)) { - appState.environment.blurRadius = - activated ? appState.settings.setting.backgroundBlurRadius : 0 - } - case .setHomeListType(let type): - appState.environment.homeListType = type - case .setFavoritesIndex(let index): - appState.environment.favoritesIndex = index - case .setToplistsType(let type): - appState.environment.toplistsType = type - case .setNavigationBarHidden(let hidden): - appState.environment.navigationBarHidden = hidden - case .setHomeViewSheetState(let state): - if state != nil { HapticUtil.generateFeedback(style: .light) } - appState.environment.homeViewSheetState = state - case .setSettingViewSheetState(let state): - if state != nil { HapticUtil.generateFeedback(style: .light) } - appState.environment.settingViewSheetState = state - case .setDetailViewSheetState(let state): - if state != nil { HapticUtil.generateFeedback(style: .light) } - appState.environment.detailViewSheetState = state - case .setCommentViewSheetState(let state): - if state != nil { HapticUtil.generateFeedback(style: .light) } - appState.environment.commentViewSheetState = state - - // MARK: Fetch Data - case .handleJumpPage(let index, let keyword): - DispatchQueue.main.async { [weak self] in - switch appState.environment.homeListType { - case .search: - guard let keyword = keyword else { break } - self?.dispatch(.fetchSearchItems(keyword: keyword, pageNum: index)) - case .frontpage: - self?.dispatch(.fetchFrontpageItems(pageNum: index)) - case .watched: - self?.dispatch(.fetchWatchedItems(pageNum: index)) - case .favorites: - self?.dispatch(.fetchFavoritesItems(pageNum: index)) - case .toplists: - self?.dispatch(.fetchToplistsItems(pageNum: index)) - case .popular, .downloaded, .history: - break - } - } - case .fetchIgneous: - appCommand = FetchIgneousCommand() - case .fetchTagTranslator: - guard let preferredLanguage = Locale.preferredLanguages.first, - let language = TranslatableLanguage.allCases.compactMap({ lang in - preferredLanguage.contains(lang.languageCode) ? lang : nil - }).first - else { - appState.settings.tagTranslator = TagTranslator() - appState.settings.setting.translatesTags = false - break - } - if appState.settings.tagTranslator.language != language { - appState.settings.tagTranslator = TagTranslator() - } - - let updatedDate = appState.settings.tagTranslator.updatedDate - appCommand = FetchTagTranslatorCommand(language: language, updatedDate: updatedDate) - case .fetchTagTranslatorDone(let result): - if case .success(let tagTranslator) = result { - appState.settings.tagTranslator = tagTranslator - } - - case .fetchGreeting: - if appState.settings.greetingLoading { break } - appState.settings.greetingLoading = true - - appCommand = FetchGreetingCommand() - case .fetchGreetingDone(let result): - appState.settings.greetingLoading = false - - switch result { - case .success(let greeting): - appState.settings.insert(greeting: greeting) - case .failure(let error): - if error == .parseFailed { - var greeting = Greeting() - greeting.updateTime = Date() - appState.settings.insert(greeting: greeting) - } - } - - case .fetchUserInfo: - let uid = appState.settings.user.apiuid - guard !uid.isEmpty, !appState.settings.userInfoLoading - else { break } - appState.settings.userInfoLoading = true - - appCommand = FetchUserInfoCommand(uid: uid) - case .fetchUserInfoDone(let result): - appState.settings.userInfoLoading = false - - if case .success(let user) = result { - appState.settings.update(user: user) - } - - case .fetchFavoriteNames: - if appState.settings.favoriteNamesLoading { break } - appState.settings.favoriteNamesLoading = true - - appCommand = FetchFavoriteNamesCommand() - case .fetchFavoriteNamesDone(let result): - appState.settings.favoriteNamesLoading = false - - if case .success(let names) = result { - appState.settings.user.favoriteNames = names - } - - case .fetchGalleryItemReverse(var url, let shouldParseGalleryURL): - appState.environment.galleryItemReverseLoadFailed = false - - guard let tmpURL = URL(string: url), - tmpURL.pathComponents.count >= 4 else { break } - if appState.environment.galleryItemReverseLoading { break } - appState.environment.galleryItemReverseLoading = true - - if appState.settings.setting.redirectsLinksToSelectedHost { - url = url.replacingOccurrences( - of: Defaults.URL.ehentai, - with: Defaults.URL.host - ) - .replacingOccurrences( - of: Defaults.URL.exhentai, - with: Defaults.URL.host - ) - } - appCommand = FetchGalleryItemReverseCommand( - gid: URLUtil.parseGID(url: tmpURL, isGalleryURL: shouldParseGalleryURL), - url: url, shouldParseGalleryURL: shouldParseGalleryURL - ) - case .fetchGalleryItemReverseDone(let carriedValue, let result): - appState.environment.galleryItemReverseLoading = false - - switch result { - case .success(let gallery): - PersistenceController.add(galleries: [gallery]) - appState.environment.galleryItemReverseID = gallery.gid - case .failure: - appState.environment.galleryItemReverseLoadFailed = true - dispatch(.setPendingJumpInfos(gid: carriedValue, pageIndex: nil, commentID: nil)) - } - - case .fetchSearchItems(let keyword, let pageNum): - appState.homeInfo.searchLoadError = nil - - if appState.homeInfo.searchLoading { break } - appState.homeInfo.searchPageNumber.current = 0 - appState.homeInfo.searchLoading = true - - let filter = appState.settings.searchFilter - appCommand = FetchSearchItemsCommand(keyword: keyword, filter: filter, pageNum: pageNum) - case .fetchSearchItemsDone(let result): - appState.homeInfo.searchLoading = false - - switch result { - case .success(let (pageNumber, galleries)): - appState.homeInfo.searchItems = galleries - PersistenceController.add(galleries: galleries) - appState.homeInfo.searchPageNumber = pageNumber - case .failure(let error): - appState.homeInfo.searchLoadError = error - } - - case .fetchMoreSearchItems(let keyword): - appState.homeInfo.moreSearchLoadFailed = false - - let pageNumber = appState.homeInfo.searchPageNumber - if pageNumber.current + 1 > pageNumber.maximum { break } - - if appState.homeInfo.moreSearchLoading { break } - appState.homeInfo.moreSearchLoading = true - - let pageNum = pageNumber.current + 1 - let filter = appState.settings.searchFilter - let lastID = appState.homeInfo.searchItems.last?.id ?? "" - appCommand = FetchMoreSearchItemsCommand( - keyword: keyword, filter: filter, - lastID: lastID, pageNum: pageNum - ) - case .fetchMoreSearchItemsDone(let result): - appState.homeInfo.moreSearchLoading = false - - switch result { - case .success(let (pageNumber, galleries)): - appState.homeInfo.searchPageNumber = pageNumber - appState.homeInfo.insertSearchItems(galleries: galleries) - PersistenceController.add(galleries: galleries) - case .failure: - appState.homeInfo.moreSearchLoadFailed = true - } - - case .fetchFrontpageItems(let pageNum): - appState.homeInfo.frontpageLoadError = nil - - if appState.homeInfo.frontpageLoading { break } - appState.homeInfo.frontpagePageNumber.current = 0 - appState.homeInfo.frontpageLoading = true - let filter = appState.settings.globalFilter - appCommand = FetchFrontpageItemsCommand(filter: filter, pageNum: pageNum) - case .fetchFrontpageItemsDone(let result): - appState.homeInfo.frontpageLoading = false - - switch result { - case .success(let (pageNumber, galleries)): - appState.homeInfo.frontpagePageNumber = pageNumber - appState.homeInfo.frontpageItems = galleries - PersistenceController.add(galleries: galleries) - case .failure(let error): - appState.homeInfo.frontpageLoadError = error - } - - case .fetchMoreFrontpageItems: - appState.homeInfo.moreFrontpageLoadFailed = false - - let pageNumber = appState.homeInfo.frontpagePageNumber - if pageNumber.current + 1 > pageNumber.maximum { break } - - if appState.homeInfo.moreFrontpageLoading { break } - appState.homeInfo.moreFrontpageLoading = true - - let pageNum = pageNumber.current + 1 - let filter = appState.settings.globalFilter - let lastID = appState.homeInfo.frontpageItems.last?.id ?? "" - appCommand = FetchMoreFrontpageItemsCommand(filter: filter, lastID: lastID, pageNum: pageNum) - case .fetchMoreFrontpageItemsDone(let result): - appState.homeInfo.moreFrontpageLoading = false - - switch result { - case .success(let (pageNumber, galleries)): - appState.homeInfo.frontpagePageNumber = pageNumber - appState.homeInfo.insertFrontpageItems(galleries: galleries) - PersistenceController.add(galleries: galleries) - case .failure: - appState.homeInfo.moreFrontpageLoadFailed = true - } - - case .fetchPopularItems: - appState.homeInfo.popularLoadError = nil - - if appState.homeInfo.popularLoading { break } - appState.homeInfo.popularLoading = true - let filter = appState.settings.globalFilter - appCommand = FetchPopularItemsCommand(filter: filter) - case .fetchPopularItemsDone(let result): - appState.homeInfo.popularLoading = false - - switch result { - case .success(let galleries): - appState.homeInfo.popularItems = galleries - PersistenceController.add(galleries: galleries) - case .failure(let error): - appState.homeInfo.popularLoadError = error - } - - case .fetchWatchedItems(let pageNum): - appState.homeInfo.watchedLoadError = nil - - if appState.homeInfo.watchedLoading { break } - appState.homeInfo.watchedPageNumber.current = 0 - appState.homeInfo.watchedLoading = true - let filter = appState.settings.globalFilter - appCommand = FetchWatchedItemsCommand(filter: filter, pageNum: pageNum) - case .fetchWatchedItemsDone(let result): - appState.homeInfo.watchedLoading = false - - switch result { - case .success(let (pageNumber, galleries)): - appState.homeInfo.watchedPageNumber = pageNumber - appState.homeInfo.watchedItems = galleries - PersistenceController.add(galleries: galleries) - case .failure(let error): - appState.homeInfo.watchedLoadError = error - } - - case .fetchMoreWatchedItems: - appState.homeInfo.moreWatchedLoadFailed = false - - let pageNumber = appState.homeInfo.watchedPageNumber - if pageNumber.current + 1 > pageNumber.maximum { break } - - if appState.homeInfo.moreWatchedLoading { break } - appState.homeInfo.moreWatchedLoading = true - - let pageNum = pageNumber.current + 1 - let filter = appState.settings.globalFilter - let lastID = appState.homeInfo.watchedItems.last?.id ?? "" - appCommand = FetchMoreWatchedItemsCommand(filter: filter, lastID: lastID, pageNum: pageNum) - case .fetchMoreWatchedItemsDone(let result): - appState.homeInfo.moreWatchedLoading = false - - switch result { - case .success(let (pageNumber, galleries)): - appState.homeInfo.watchedPageNumber = pageNumber - appState.homeInfo.insertWatchedItems(galleries: galleries) - PersistenceController.add(galleries: galleries) - case .failure: - appState.homeInfo.moreWatchedLoadFailed = true - } - - case .fetchFavoritesItems(let pageNum, let sortOrder): - let favIndex = appState.environment.favoritesIndex - appState.homeInfo.favoritesLoadErrors[favIndex] = nil - - if appState.homeInfo.favoritesLoading[favIndex] == true { break } - if appState.homeInfo.favoritesPageNumbers[favIndex] == nil { - appState.homeInfo.favoritesPageNumbers[favIndex] = PageNumber() - } - appState.homeInfo.favoritesPageNumbers[favIndex]?.current = 0 - appState.homeInfo.favoritesLoading[favIndex] = true - appCommand = FetchFavoritesItemsCommand(favIndex: favIndex, pageNum: pageNum, sortOrder: sortOrder) - case .fetchFavoritesItemsDone(let carriedValue, let result): - appState.homeInfo.favoritesLoading[carriedValue] = false - - switch result { - case .success(let (pageNumber, sortOrder, galleries)): - appState.homeInfo.favoritesPageNumbers[carriedValue] = pageNumber - appState.homeInfo.favoritesItems[carriedValue] = galleries - appState.environment.favoritesSortOrder = sortOrder - PersistenceController.add(galleries: galleries) - case .failure(let error): - appState.homeInfo.favoritesLoadErrors[carriedValue] = error - } - - case .fetchMoreFavoritesItems: - let favIndex = appState.environment.favoritesIndex - appState.homeInfo.moreFavoritesLoadFailed[favIndex] = false - - let pageNumber = appState.homeInfo.favoritesPageNumbers[favIndex] - if (pageNumber?.current ?? 0) + 1 > pageNumber?.maximum ?? 0 { break } - - if appState.homeInfo.moreFavoritesLoading[favIndex] == true { break } - appState.homeInfo.moreFavoritesLoading[favIndex] = true - - let pageNum = (pageNumber?.current ?? 0) + 1 - let lastID = appState.homeInfo.favoritesItems[favIndex]?.last?.id ?? "" - appCommand = FetchMoreFavoritesItemsCommand( - favIndex: favIndex, lastID: lastID, pageNum: pageNum - ) - case .fetchMoreFavoritesItemsDone(let carriedValue, let result): - appState.homeInfo.moreFavoritesLoading[carriedValue] = false - - switch result { - case .success(let (pageNumber, sortOrder, galleries)): - appState.homeInfo.favoritesPageNumbers[carriedValue] = pageNumber - appState.homeInfo.insertFavoritesItems(favIndex: carriedValue, galleries: galleries) - appState.environment.favoritesSortOrder = sortOrder - PersistenceController.add(galleries: galleries) - case .failure: - appState.homeInfo.moreFavoritesLoading[carriedValue] = true - } - - case .fetchToplistsItems(let pageNum): - let topType = appState.environment.toplistsType - appState.homeInfo.toplistsLoadErrors[topType.rawValue] = nil - - if appState.homeInfo.toplistsLoading[topType.rawValue] == true { break } - if appState.homeInfo.toplistsPageNumbers[topType.rawValue] == nil { - appState.homeInfo.toplistsPageNumbers[topType.rawValue] = PageNumber() - } - appState.homeInfo.toplistsPageNumbers[topType.rawValue]?.current = 0 - appState.homeInfo.toplistsLoading[topType.rawValue] = true - appCommand = FetchToplistsItemsCommand( - topIndex: topType.rawValue, catIndex: topType.categoryIndex, pageNum: pageNum - ) - case .fetchToplistsItemsDone(let carriedValue, let result): - appState.homeInfo.toplistsLoading[carriedValue] = false - - switch result { - case .success(let (pageNumber, galleries)): - appState.homeInfo.toplistsPageNumbers[carriedValue] = pageNumber - appState.homeInfo.toplistsItems[carriedValue] = galleries - PersistenceController.add(galleries: galleries) - case .failure(let error): - appState.homeInfo.toplistsLoadErrors[carriedValue] = error - } - - case .fetchMoreToplistsItems: - let topType = appState.environment.toplistsType - appState.homeInfo.moreToplistsLoadFailed[topType.rawValue] = false - - let pageNumber = appState.homeInfo.toplistsPageNumbers[topType.rawValue] - if (pageNumber?.current ?? 0) + 1 > pageNumber?.maximum ?? 0 { break } - - if appState.homeInfo.moreToplistsLoading[topType.rawValue] == true { break } - appState.homeInfo.moreToplistsLoading[topType.rawValue] = true - - let pageNum = (pageNumber?.current ?? 0) + 1 - appCommand = FetchMoreToplistsItemsCommand( - topIndex: topType.rawValue, catIndex: topType.categoryIndex, pageNum: pageNum - ) - case .fetchMoreToplistsItemsDone(let carriedValue, let result): - appState.homeInfo.moreToplistsLoading[carriedValue] = false - - switch result { - case .success(let (pageNumber, galleries)): - appState.homeInfo.toplistsPageNumbers[carriedValue] = pageNumber - appState.homeInfo.insertToplistsItems(topIndex: carriedValue, galleries: galleries) - PersistenceController.add(galleries: galleries) - case .failure: - appState.homeInfo.moreToplistsLoading[carriedValue] = true - } - - case .fetchGalleryDetail(let gid): - appState.detailInfo.detailLoadErrors[gid] = nil - - if appState.detailInfo.detailLoading[gid] == true { break } - appState.detailInfo.detailLoading[gid] = true - - let galleryURL = PersistenceController.fetchGallery(gid: gid)?.galleryURL ?? "" - appCommand = FetchGalleryDetailCommand(gid: gid, galleryURL: galleryURL) - case .fetchGalleryDetailDone(let gid, let result): - appState.detailInfo.detailLoading[gid] = false - - switch result { - case .success(let (detail, state, apiKey, greeting)): - appState.settings.user.apikey = apiKey - if let greeting = greeting { - appState.settings.insert(greeting: greeting) - } - if let previewConfig = state.previewConfig { - appState.detailInfo.previewConfig = previewConfig - } - PersistenceController.add(detail: detail) - PersistenceController.update(fetchedState: state) - appState.detailInfo.update(gid: gid, previews: state.previews) - case .failure(let error): - appState.detailInfo.detailLoadErrors[gid] = error - } - - case .fetchGalleryArchiveFunds(let gid): - if appState.detailInfo.archiveFundsLoading { break } - appState.detailInfo.archiveFundsLoading = true - let galleryURL = PersistenceController.fetchGallery(gid: gid)?.galleryURL ?? "" - appCommand = FetchGalleryArchiveFundsCommand(gid: gid, galleryURL: galleryURL) - case .fetchGalleryArchiveFundsDone(let result): - appState.detailInfo.archiveFundsLoading = false - - if case .success(let (currentGP, currentCredits)) = result { - appState.settings.update( - user: User( - currentGP: currentGP, - currentCredits: currentCredits - ) - ) - } - - case .fetchGalleryPreviews(let gid, let index): - let pageNumber = appState.detailInfo.previewConfig.pageNumber(index: index) - if appState.detailInfo.previewsLoading[gid] == nil { - appState.detailInfo.previewsLoading[gid] = [:] - } - - if appState.detailInfo.previewsLoading[gid]?[pageNumber] == true { break } - appState.detailInfo.previewsLoading[gid]?[pageNumber] = true - - let galleryURL = PersistenceController.fetchGallery(gid: gid)?.galleryURL ?? "" - let url = Defaults.URL.detailPage(url: galleryURL, pageNum: pageNumber) - appCommand = FetchGalleryPreviewsCommand(gid: gid, url: url, pageNumber: pageNumber) - - case .fetchGalleryPreviewsDone(let gid, let pageNumber, let result): - appState.detailInfo.previewsLoading[gid]?[pageNumber] = false - - if case .success(let previews) = result { - appState.detailInfo.update(gid: gid, previews: previews) - PersistenceController.update(fetchedState: GalleryState(gid: gid, previews: previews)) - } - - case .fetchMPVKeys(let gid, let index, let mpvURL): - let pageCount = PersistenceController.fetchGallery(gid: gid)?.pageCount ?? -1 - appCommand = FetchMPVKeysCommand(gid: gid, mpvURL: mpvURL, pageCount: pageCount, index: index) - case .fetchMPVKeysDone(let gid, let index, let result): - let batchRange = appState.detailInfo.previewConfig.batchRange(index: index) - batchRange.forEach { appState.contentInfo.contentsLoading[gid]?[$0] = false } - - switch result { - case .success(let (mpvKey, imgKeys)): - appState.contentInfo.mpvKeys[gid] = mpvKey - appState.contentInfo.mpvImageKeys[gid] = imgKeys - - if appState.contentInfo.contents[gid]?.isEmpty == true, - let pageCount = PersistenceController.fetchGallery(gid: gid)?.pageCount { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - Array(1...min(3, max(1, pageCount))).forEach { index in - self?.dispatch(.fetchGalleryMPVContent(gid: gid, index: index)) - } - } - } - case .failure(let error): - batchRange.forEach { appState.contentInfo.contentsLoadErrors[gid]?[$0] = error } - } - - case .fetchThumbnails(let gid, let index): - let batchRange = appState.detailInfo.previewConfig.batchRange(index: index) - let pageNumber = appState.detailInfo.previewConfig.pageNumber(index: index) - if appState.contentInfo.contentsLoading[gid] == nil { - appState.contentInfo.contentsLoading[gid] = [:] - } - if appState.contentInfo.contentsLoadErrors[gid] == nil { - appState.contentInfo.contentsLoadErrors[gid] = [:] - } - batchRange.forEach { appState.contentInfo.contentsLoadErrors[gid]?[$0] = nil } - - if appState.contentInfo.contentsLoading[gid]?[index] == true { break } - batchRange.forEach { appState.contentInfo.contentsLoading[gid]?[$0] = true } - - let url = PersistenceController.fetchGallery(gid: gid)?.galleryURL ?? "" - let galleryURL = Defaults.URL.detailPage(url: url, pageNum: pageNumber) - appCommand = FetchThumbnailsCommand(gid: gid, index: index, url: galleryURL) - case .fetchThumbnailsDone(let gid, let index, let result): - let batchRange = appState.detailInfo.previewConfig.batchRange(index: index) - switch result { - case .success(let thumbnails): - let thumbnailURL = thumbnails[index]?.safeURL() - if thumbnailURL?.pathComponents.count ?? 0 >= 1, thumbnailURL?.pathComponents[1] == "mpv" { - dispatch(.fetchMPVKeys(gid: gid, index: index, mpvURL: thumbnailURL?.absoluteString ?? "")) - } else { - dispatch(.fetchGalleryNormalContents( - gid: gid, index: index, thumbnails: thumbnails - )) - appState.contentInfo.update(gid: gid, thumbnails: thumbnails) - PersistenceController.update(gid: gid, thumbnails: thumbnails) - } - case .failure(let error): - batchRange.forEach { index in - appState.contentInfo.contentsLoading[gid]?[index] = false - appState.contentInfo.contentsLoadErrors[gid]?[index] = error - } - } - - case .fetchGalleryNormalContents(let gid, let index, let thumbnails): - appCommand = FetchGalleryNormalContentsCommand( - gid: gid, index: index, thumbnails: thumbnails - ) - case .fetchGalleryNormalContentsDone(let gid, let index, let result): - let batchRange = appState.detailInfo.previewConfig.batchRange(index: index) - batchRange.forEach { appState.contentInfo.contentsLoading[gid]?[$0] = false } - - switch result { - case .success(let (contents, originalContents)): - appState.contentInfo.update(gid: gid, contents: contents, originalContents: originalContents) - PersistenceController.update(gid: gid, contents: contents, originalContents: originalContents) - case .failure(let error): - batchRange.forEach { appState.contentInfo.contentsLoadErrors[gid]?[$0] = error } - } - - case .refetchGalleryNormalContent(let gid, let index): - let pageNumber = appState.detailInfo.previewConfig.pageNumber(index: index) - appState.contentInfo.contentsLoadErrors[gid]?[index] = nil - - if appState.contentInfo.contentsLoading[gid]?[index] == true { break } - appState.contentInfo.contentsLoading[gid]?[index] = true - - let url = PersistenceController.fetchGallery(gid: gid)?.galleryURL ?? "" - let galleryURL = Defaults.URL.detailPage(url: url, pageNum: pageNumber) - let thumbnailURL = appState.contentInfo.thumbnails[gid]?[index] - let storedImageURL = appState.contentInfo.contents[gid]?[index] ?? "" - appCommand = RefetchGalleryNormalContentCommand( - gid: gid, index: index, galleryURL: galleryURL, - thumbnailURL: thumbnailURL, storedImageURL: storedImageURL, - bypassesSNIFiltering: appState.settings.setting.bypassesSNIFiltering - ) - case .refetchGalleryNormalContentDone(let gid, let index, let result): - appState.contentInfo.contentsLoading[gid]?[index] = false - - switch result { - case .success(let content): - appState.contentInfo.update(gid: gid, contents: content, originalContents: [:]) - PersistenceController.update(gid: gid, contents: content, originalContents: [:]) - case .failure(let error): - appState.contentInfo.contentsLoadErrors[gid]?[index] = error - } - - case .fetchGalleryMPVContent(let gid, let index, let isRefetch): - guard let gidInteger = Int(gid), - let mpvKey = appState.contentInfo.mpvKeys[gid], - let imgKey = appState.contentInfo.mpvImageKeys[gid]?[index] - else { break } - - appState.contentInfo.contentsLoadErrors[gid]?[index] = nil - - if appState.contentInfo.contentsLoading[gid]?[index] == true { break } - appState.contentInfo.contentsLoading[gid]?[index] = true - - let reloadToken = isRefetch ? appState.contentInfo.mpvReloadTokens[gid]?[index] : nil - appCommand = FetchGalleryMPVContentCommand( - gid: gidInteger, index: index, mpvKey: mpvKey, imgKey: imgKey, reloadToken: reloadToken - ) - case .fetchGalleryMPVContentDone(let gid, let index, let result): - appState.contentInfo.contentsLoading[gid]?[index] = false - - if case .success(let (imageURL, originalImageURL, reloadToken)) = result { - var originalContents = [Int: String]() - if let originalImageURL = originalImageURL { - originalContents[index] = originalImageURL - } - appState.contentInfo.update(gid: gid, contents: [index: imageURL], originalContents: originalContents) - PersistenceController.update(gid: gid, contents: [index: imageURL], originalContents: originalContents) - if appState.contentInfo.mpvReloadTokens[gid] == nil { - appState.contentInfo.mpvReloadTokens[gid] = [index: reloadToken] - } else { - appState.contentInfo.mpvReloadTokens[gid]?[index] = reloadToken - } - } - - // MARK: Account Ops - case .createEhProfile(let name): - appCommand = CreateEhProfileCommand(name: name) - case .verifyEhProfile: - appCommand = VerifyEhProfileCommand() - case .verifyEhProfileDone(let result): - if case .success(let (profileValue, profileNotFound)) = result { - if let profileValue = profileValue { - let profileValueString = String(profileValue) - let hostURL = Defaults.URL.host.safeURL() - let selectedProfileKey = Defaults.Cookie.selectedProfile - - let cookieValue = CookiesUtil.get(for: hostURL, key: selectedProfileKey) - if cookieValue.rawValue != profileValueString { - CookiesUtil.set(for: hostURL, key: selectedProfileKey, value: profileValueString) - } - } else if profileNotFound { - dispatch(.createEhProfile(name: "EhPanda")) - } else { - Logger.error("Found profile but failed in parsing value.") - } - } - case .favorGallery(let gid, let favIndex): - let token = PersistenceController.fetchGallery(gid: gid)?.token ?? "" - appCommand = AddFavoriteCommand(gid: gid, token: token, favIndex: favIndex) - case .unfavorGallery(let gid): - appCommand = DeleteFavoriteCommand(gid: gid) - - case .rateGallery(let gid, let rating): - let apiuidString = appState.settings.user.apiuid - guard !apiuidString.isEmpty, - let apikey = appState.settings.user.apikey, - let token = PersistenceController.fetchGallery(gid: gid)?.token, - let apiuid = Int(apiuidString), - let gid = Int(gid) - else { break } - - appCommand = RateCommand( - apiuid: apiuid, - apikey: apikey, - gid: gid, - token: token, - rating: rating - ) - - case .commentGallery(let gid, let content): - let galleryURL = PersistenceController.fetchGallery(gid: gid)?.galleryURL ?? "" - appCommand = CommentCommand(gid: gid, content: content, galleryURL: galleryURL) - case .editGalleryComment(let gid, let commentID, let content): - let galleryURL = PersistenceController.fetchGallery(gid: gid)?.galleryURL ?? "" - - appCommand = EditCommentCommand( - gid: gid, - commentID: commentID, - content: content, - galleryURL: galleryURL - ) - case .voteGalleryComment(let gid, let commentID, let vote): - let apiuidString = appState.settings.user.apiuid - guard !apiuidString.isEmpty, - let apikey = appState.settings.user.apikey, - let token = PersistenceController.fetchGallery(gid: gid)?.token, - let commentID = Int(commentID), - let apiuid = Int(apiuidString), - let gid = Int(gid) - else { break } - - appCommand = VoteCommentCommand( - apiuid: apiuid, - apikey: apikey, - gid: gid, - token: token, - commentID: commentID, - commentVote: vote - ) - } - - return (appState, appCommand) - } -} diff --git a/EhPanda/DataFlow/StoreAccessor.swift b/EhPanda/DataFlow/StoreAccessor.swift deleted file mode 100644 index 0001297e..00000000 --- a/EhPanda/DataFlow/StoreAccessor.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// StoreAccessor.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/05/04. -// - -import SwiftUI - -protocol StoreAccessor { - var store: Store { get } -} - -// MARK: AppState -extension StoreAccessor { - var appState: AppState { - store.appState - } - var environment: AppState.Environment { - appState.environment - } - var settings: AppState.Settings { - appState.settings - } - var homeInfo: AppState.HomeInfo { - appState.homeInfo - } - var detailInfo: AppState.DetailInfo { - appState.detailInfo - } - var contentInfo: AppState.ContentInfo { - appState.contentInfo - } -} - -// MARK: Environment -extension StoreAccessor { - var isAppUnlocked: Bool { - environment.isAppUnlocked - } - var isSlideMenuClosed: Bool { - environment.slideMenuClosed - } - var homeListType: HomeListType { - environment.homeListType - } - var viewControllersCount: Int { - environment.viewControllersCount - } -} - -// MARK: Settings -extension StoreAccessor { - var user: User { - settings.user - } - var currentGP: String? { - user.currentGP - } - var currentCredits: String? { - user.currentCredits - } - var favoriteNames: [Int: String]? { - user.favoriteNames - } - var setting: Setting { - settings.setting - } - var searchFilter: Filter { - settings.searchFilter - } - var globalFilter: Filter { - settings.globalFilter - } - var accentColor: Color { - setting.accentColor - } - var appIconType: IconType { - setting.appIconType - } - var backgroundBlurRadius: Double { - setting.backgroundBlurRadius - } - var autoLockPolicy: AutoLockPolicy { - setting.autoLockPolicy - } - var detectsLinksFromPasteboard: Bool { - setting.detectsLinksFromPasteboard - } -} diff --git a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift new file mode 100755 index 00000000..73fcb8ab --- /dev/null +++ b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift @@ -0,0 +1,23 @@ +// +// FileManager+ApplicationSupport.swift +// CoreDataMigration-Example +// +// Created by William Boles on 17/01/2019. +// Copyright © 2019 William Boles. All rights reserved. +// + +import Foundation + +extension FileManager { + static func clearApplicationSupportDirectoryContents() { + guard let applicationSupportURL = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask).first, + let applicationSupportDirectoryContents = try? FileManager + .default.contentsOfDirectory(atPath: applicationSupportURL.path) + else { return } + applicationSupportDirectoryContents.forEach { + let fileURL = URL(fileURLWithPath: applicationSupportURL.path, isDirectory: true).appendingPathComponent($0) + try? FileManager.default.removeItem(atPath: fileURL.path) + } + } +} diff --git a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift new file mode 100755 index 00000000..7392cc92 --- /dev/null +++ b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift @@ -0,0 +1,16 @@ +// +// NSManagedObjectModel+Compatible.swift +// CoreDataMigration-Example +// +// Created by William Boles on 02/01/2019. +// Copyright © 2019 William Boles. All rights reserved. +// + +import Foundation +import CoreData + +extension NSManagedObjectModel { + static func compatibleModelForStoreMetadata(_ metadata: [String: Any]) -> NSManagedObjectModel? { + NSManagedObjectModel.mergedModel(from: [Bundle.main], forStoreMetadata: metadata) + } +} diff --git a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift new file mode 100755 index 00000000..a4e9dd93 --- /dev/null +++ b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift @@ -0,0 +1,27 @@ +// +// NSManagedObjectModel+Resource.swift +// CoreDataMigration-Example +// +// Created by William Boles on 02/01/2019. +// Copyright © 2019 William Boles. All rights reserved. +// + +import Foundation +import CoreData + +extension NSManagedObjectModel { + static func managedObjectModel(forResource resource: String) throws -> NSManagedObjectModel { + let subdirectory = "Model.momd" + let omoURL = Bundle.main.url(forResource: resource, withExtension: "omo", subdirectory: subdirectory) + let momURL = Bundle.main.url(forResource: resource, withExtension: "mom", subdirectory: subdirectory) + + guard let url = omoURL ?? momURL else { + throw AppError.databaseCorrupted("Unable to find model in bundle.") + } + guard let model = NSManagedObjectModel(contentsOf: url) else { + throw AppError.databaseCorrupted("Unable to load model in bundle.") + } + + return model + } +} diff --git a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift b/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift new file mode 100755 index 00000000..9b325282 --- /dev/null +++ b/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift @@ -0,0 +1,51 @@ +// +// NSPersistentStoreCoordinator+SQLite.swift +// CoreDataMigration-Example +// +// Created by William Boles on 15/09/2017. +// Copyright © 2017 William Boles. All rights reserved. +// + +import CoreData + +extension NSPersistentStoreCoordinator { + static func destroyStore(at storeURL: URL) throws { + do { + let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel()) + try persistentStoreCoordinator.destroyPersistentStore(at: storeURL, ofType: NSSQLiteStoreType, options: nil) + } catch let error { + let message = ("Failed to destroy persistent store at \(storeURL), error: \(error).") + throw AppError.databaseCorrupted(message) + } + } + static func replaceStore(at targetURL: URL, withStoreAt sourceURL: URL) throws { + do { + let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel()) + try persistentStoreCoordinator.replacePersistentStore( + at: targetURL, destinationOptions: nil, + withPersistentStoreFrom: sourceURL, + sourceOptions: nil, ofType: NSSQLiteStoreType + ) + } catch let error { + let message = "Failed to replace persistent store at \(targetURL) with \(sourceURL), error: \(error)." + throw AppError.databaseCorrupted(message) + } + } + + static func metadata(at storeURL: URL) -> [String: Any]? { + try? NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, at: storeURL, options: nil + ) + } + + func addPersistentStore(at storeURL: URL, options: [AnyHashable: Any]) throws -> NSPersistentStore { + do { + return try addPersistentStore( + ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options + ) + } catch { + let message = ("Failed to add persistent store to coordinator, error: \(error).") + throw AppError.databaseCorrupted(message) + } + } +} diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift index c6404b76..6e32bec2 100644 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -16,6 +16,7 @@ extension AppEnvMO: ManagedObjectProtocol { setting: setting?.toObject() ?? Setting(), searchFilter: searchFilter?.toObject() ?? Filter(), globalFilter: globalFilter?.toObject() ?? Filter(), + watchedFilter: watchedFilter?.toObject() ?? Filter(), tagTranslator: tagTranslator?.toObject() ?? TagTranslator(), historyKeywords: historyKeywords?.toObject() ?? [String](), quickSearchWords: quickSearchWords?.toObject() ?? [QuickSearchWord]() @@ -32,6 +33,7 @@ extension AppEnv: ManagedObjectConvertible { appEnvMO.setting = setting.toData() appEnvMO.searchFilter = searchFilter.toData() appEnvMO.globalFilter = globalFilter.toData() + appEnvMO.watchedFilter = watchedFilter.toData() appEnvMO.tagTranslator = tagTranslator.toData() appEnvMO.historyKeywords = historyKeywords.toData() appEnvMO.quickSearchWords = quickSearchWords.toData() @@ -39,35 +41,3 @@ extension AppEnv: ManagedObjectConvertible { return appEnvMO } } - -struct AppEnv: Codable { - let user: User - let setting: Setting - let searchFilter: Filter - let globalFilter: Filter - let tagTranslator: TagTranslator - let historyKeywords: [String] - let quickSearchWords: [QuickSearchWord] -} - -struct TagTranslator: Codable { - var language: TranslatableLanguage = .japanese - var updatedDate: Date = .distantPast - var contents = [String: String]() - - func translate(text: String) -> String { - guard let translatedText = contents[text], - !translatedText.isEmpty - else { return text } - - return translatedText - } -} - -extension TagTranslator: CustomStringConvertible { - var description: String { - "TagTranslator(language: \(language), " - + "updatedDate: \(updatedDate), " - + "contents: \(contents.count))" - } -} diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift index 39569a65..55901017 100644 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift @@ -16,6 +16,7 @@ extension AppEnvMO { @NSManaged public var setting: Data? @NSManaged public var searchFilter: Data? @NSManaged public var globalFilter: Data? + @NSManaged public var watchedFilter: Data? @NSManaged public var tagTranslator: Data? @NSManaged public var historyKeywords: Data? @NSManaged public var quickSearchWords: Data? diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index a1d57f1f..fe936984 100644 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -12,14 +12,14 @@ public class GalleryDetailMO: NSManagedObject {} extension GalleryDetailMO: ManagedObjectProtocol { func toEntity() -> GalleryDetail { GalleryDetail( - gid: gid, title: title, jpnTitle: jpnTitle, isFavored: isFavored, + gid: gid, title: title, jpnTitle: jpnTitle, isFavorited: isFavorited, visibility: visibility?.toObject() ?? GalleryVisibility.yes, rating: rating, userRating: userRating, ratingCount: Int(ratingCount), category: Category(rawValue: category).forceUnwrapped, language: Language(rawValue: language).forceUnwrapped, uploader: uploader, postedDate: postedDate, coverURL: coverURL, archiveURL: archiveURL, parentURL: parentURL, - favoredCount: Int(favoredCount), pageCount: Int(pageCount), + favoritedCount: Int(favoritedCount), pageCount: Int(pageCount), sizeCount: sizeCount, sizeType: sizeType, torrentCount: Int(torrentCount) ) @@ -34,11 +34,11 @@ extension GalleryDetail: ManagedObjectConvertible { galleryDetailMO.archiveURL = archiveURL galleryDetailMO.category = category.rawValue galleryDetailMO.coverURL = coverURL - galleryDetailMO.isFavored = isFavored + galleryDetailMO.isFavorited = isFavorited galleryDetailMO.visibility = visibility.toData() galleryDetailMO.jpnTitle = jpnTitle galleryDetailMO.language = language.rawValue - galleryDetailMO.favoredCount = Int64(favoredCount) + galleryDetailMO.favoritedCount = Int64(favoritedCount) galleryDetailMO.pageCount = Int64(pageCount) galleryDetailMO.parentURL = parentURL galleryDetailMO.postedDate = postedDate diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift index acbf0b1b..91958f3f 100644 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift @@ -7,21 +7,21 @@ import CoreData -extension GalleryDetailMO: Identifiable { +extension GalleryDetailMO: GalleryIdentifiable { @nonobjc public class func fetchRequest() -> NSFetchRequest { NSFetchRequest(entityName: "GalleryDetailMO") } - @NSManaged public var archiveURL: String? + @NSManaged public var archiveURL: URL? @NSManaged public var category: String - @NSManaged public var coverURL: String + @NSManaged public var coverURL: URL? @NSManaged public var gid: String - @NSManaged public var isFavored: Bool + @NSManaged public var isFavorited: Bool @NSManaged public var jpnTitle: String? @NSManaged public var language: String - @NSManaged public var favoredCount: Int64 + @NSManaged public var favoritedCount: Int64 @NSManaged public var pageCount: Int64 - @NSManaged public var parentURL: String? + @NSManaged public var parentURL: URL? @NSManaged public var postedDate: Date @NSManaged public var rating: Float @NSManaged public var userRating: Float diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift index ce01561c..ba5a61c7 100644 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -13,8 +13,9 @@ extension GalleryMO: ManagedObjectProtocol { func toEntity() -> Gallery { Gallery( gid: gid, token: token, - title: title, rating: rating, tags: [], - category: Category(rawValue: category).forceUnwrapped, + title: title, rating: rating, + tagStrings: tagStrings?.toObject() ?? [String](), + category: Category(rawValue: category) ?? .private, language: Language(rawValue: language ?? ""), uploader: uploader, pageCount: Int(pageCount), postedDate: postedDate, @@ -37,6 +38,7 @@ extension Gallery: ManagedObjectConvertible { galleryMO.pageCount = Int64(pageCount) galleryMO.postedDate = postedDate galleryMO.rating = rating + galleryMO.tagStrings = tagStrings.toData() galleryMO.title = title galleryMO.token = token galleryMO.uploader = uploader diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift index cd049512..30053771 100644 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift @@ -7,20 +7,21 @@ import CoreData -extension GalleryMO: Identifiable, GalleryIdentifiable { +extension GalleryMO: GalleryIdentifiable { @nonobjc public class func fetchRequest() -> NSFetchRequest { NSFetchRequest(entityName: "GalleryMO") } @NSManaged public var category: String - @NSManaged public var coverURL: String - @NSManaged public var galleryURL: String + @NSManaged public var coverURL: URL? + @NSManaged public var galleryURL: URL? @NSManaged public var gid: String @NSManaged public var language: String? @NSManaged public var lastOpenDate: Date? @NSManaged public var pageCount: Int64 @NSManaged public var postedDate: Date @NSManaged public var rating: Float + @NSManaged public var tagStrings: Data? @NSManaged public var title: String @NSManaged public var token: String @NSManaged public var uploader: String? diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index 7eebe80d..5b486d21 100644 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -15,11 +15,12 @@ extension GalleryStateMO: ManagedObjectProtocol { GalleryState( gid: gid, tags: tags?.toObject() ?? [GalleryTag](), readingProgress: Int(readingProgress), - previews: previews?.toObject() ?? [Int: String](), + previewURLs: previewURLs?.toObject() ?? [Int: URL](), + previewConfig: previewConfig?.toObject() ?? PreviewConfig.normal(rows: 4), comments: comments?.toObject() ?? [GalleryComment](), - contents: contents?.toObject() ?? [Int: String](), - originalContents: originalContents?.toObject() ?? [Int: String](), - thumbnails: thumbnails?.toObject() ?? [Int: String]() + imageURLs: imageURLs?.toObject() ?? [Int: URL](), + originalImageURLs: originalImageURLs?.toObject() ?? [Int: URL](), + thumbnailURLs: thumbnailURLs?.toObject() ?? [Int: URL]() ) } } @@ -32,11 +33,12 @@ extension GalleryState: ManagedObjectConvertible { galleryStateMO.gid = gid galleryStateMO.tags = tags.toData() galleryStateMO.readingProgress = Int64(readingProgress) - galleryStateMO.previews = previews.toData() + galleryStateMO.previewConfig = previewConfig?.toData() + galleryStateMO.previewURLs = previewURLs.toData() galleryStateMO.comments = comments.toData() - galleryStateMO.contents = contents.toData() - galleryStateMO.originalContents = originalContents.toData() - galleryStateMO.thumbnails = thumbnails.toData() + galleryStateMO.imageURLs = imageURLs.toData() + galleryStateMO.originalImageURLs = originalImageURLs.toData() + galleryStateMO.thumbnailURLs = thumbnailURLs.toData() return galleryStateMO } diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift index dd14249d..91fed52f 100644 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift @@ -13,11 +13,12 @@ extension GalleryStateMO: GalleryIdentifiable { } @NSManaged public var comments: Data? - @NSManaged public var contents: Data? - @NSManaged public var originalContents: Data? + @NSManaged public var imageURLs: Data? + @NSManaged public var originalImageURLs: Data? @NSManaged public var gid: String - @NSManaged public var previews: Data? + @NSManaged public var previewConfig: Data? + @NSManaged public var previewURLs: Data? @NSManaged public var readingProgress: Int64 @NSManaged public var tags: Data? - @NSManaged public var thumbnails: Data? + @NSManaged public var thumbnailURLs: Data? } diff --git a/EhPanda/Database/Migration/CoreDataMigrationStep.swift b/EhPanda/Database/Migration/CoreDataMigrationStep.swift new file mode 100755 index 00000000..07cd865d --- /dev/null +++ b/EhPanda/Database/Migration/CoreDataMigrationStep.swift @@ -0,0 +1,54 @@ +// +// CoreDataMigrationStep.swift +// CoreDataMigration-Example +// +// Created by William Boles on 11/09/2017. +// Copyright © 2017 William Boles. All rights reserved. +// + +import CoreData + +struct CoreDataMigrationStep { + let sourceModel: NSManagedObjectModel + let destinationModel: NSManagedObjectModel + let mappingModel: NSMappingModel + + init(sourceVersion: CoreDataMigrationVersion, destinationVersion: CoreDataMigrationVersion) throws { + let sourceModel = try NSManagedObjectModel.managedObjectModel(forResource: sourceVersion.rawValue) + let destinationModel = try NSManagedObjectModel.managedObjectModel(forResource: destinationVersion.rawValue) + + guard let mappingModel = CoreDataMigrationStep.mappingModel( + fromSourceModel: sourceModel, toDestinationModel: destinationModel + ) else { + throw AppError.databaseCorrupted("Expected modal mapping not present.") + } + + self.sourceModel = sourceModel + self.destinationModel = destinationModel + self.mappingModel = mappingModel + } + + private static func mappingModel( + fromSourceModel sourceModel: NSManagedObjectModel, + toDestinationModel destinationModel: NSManagedObjectModel + ) -> NSMappingModel? { + guard let customMapping = customMappingModel( + fromSourceModel: sourceModel, toDestinationModel: destinationModel + ) else { + return inferredMappingModel(fromSourceModel: sourceModel, toDestinationModel: destinationModel) + } + return customMapping + } + private static func inferredMappingModel( + fromSourceModel sourceModel: NSManagedObjectModel, + toDestinationModel destinationModel: NSManagedObjectModel + ) -> NSMappingModel? { + try? NSMappingModel.inferredMappingModel(forSourceModel: sourceModel, destinationModel: destinationModel) + } + private static func customMappingModel( + fromSourceModel sourceModel: NSManagedObjectModel, + toDestinationModel destinationModel: NSManagedObjectModel + ) -> NSMappingModel? { + NSMappingModel(from: [Bundle.main], forSourceModel: sourceModel, destinationModel: destinationModel) + } +} diff --git a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift new file mode 100755 index 00000000..ffab66ed --- /dev/null +++ b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift @@ -0,0 +1,43 @@ +// +// CoreDataVersion.swift +// CoreDataMigration-Example +// +// Created by William Boles on 02/01/2019. +// Copyright © 2019 William Boles. All rights reserved. +// + +import Foundation +import CoreData + +enum CoreDataMigrationVersion: String, CaseIterable { + case version1 = "Model" + case version2 = "Model 2" + case version3 = "Model 3" + case version4 = "Model 4" + case version5 = "Model 5" + case version6 = "Model 6" + + static func current() throws -> CoreDataMigrationVersion { + guard let latest = allCases.last else { + throw AppError.databaseCorrupted("No model versions found.") + } + return latest + } + + func nextVersion() -> CoreDataMigrationVersion? { + switch self { + case .version1: + return .version2 + case .version2: + return .version3 + case .version3: + return .version4 + case .version4: + return .version5 + case .version5: + return .version6 + case .version6: + return nil + } + } +} diff --git a/EhPanda/Database/Migration/CoreDataMigrator.swift b/EhPanda/Database/Migration/CoreDataMigrator.swift new file mode 100755 index 00000000..7588e93b --- /dev/null +++ b/EhPanda/Database/Migration/CoreDataMigrator.swift @@ -0,0 +1,113 @@ +// +// CoreDataMigrator.swift +// CoreDataMigration-Example +// +// Created by William Boles on 11/09/2017. +// Copyright © 2017 William Boles. All rights reserved. +// + +import CoreData + +protocol CoreDataMigratorProtocol { + func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool + func migrateStore(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws +} + +class CoreDataMigrator: CoreDataMigratorProtocol { + func requiresMigration(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws -> Bool { + guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL) else { return false } + return (try CoreDataMigrationVersion.compatibleVersionForStoreMetadata(metadata) != version) + } + + func migrateStore(at storeURL: URL, toVersion version: CoreDataMigrationVersion) throws { + try forceWALCheckpointingForStore(at: storeURL) + + var currentURL = storeURL + let migrationSteps = try migrationStepsForStore(at: storeURL, toVersion: version) + + for migrationStep in migrationSteps { + let manager = NSMigrationManager( + sourceModel: migrationStep.sourceModel, destinationModel: migrationStep.destinationModel + ) + let destinationURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString) + + do { + try manager.migrateStore( + from: currentURL, sourceType: NSSQLiteStoreType, options: nil, + with: migrationStep.mappingModel, toDestinationURL: destinationURL, + destinationType: NSSQLiteStoreType, destinationOptions: nil + ) + } catch { + let message = "Failed attempting to migrate from \(migrationStep.sourceModel) " + + "to \(migrationStep.destinationModel), error: \(error)." + throw AppError.databaseCorrupted(message) + } + + if currentURL != storeURL { + try NSPersistentStoreCoordinator.destroyStore(at: currentURL) + } + + currentURL = destinationURL + } + + try NSPersistentStoreCoordinator.replaceStore(at: storeURL, withStoreAt: currentURL) + + if currentURL != storeURL { + try NSPersistentStoreCoordinator.destroyStore(at: currentURL) + } + } + + private func migrationStepsForStore( + at storeURL: URL, toVersion destinationVersion: CoreDataMigrationVersion + ) throws -> [CoreDataMigrationStep] { + guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL), + let sourceVersion = try CoreDataMigrationVersion.compatibleVersionForStoreMetadata(metadata) + else { + throw AppError.databaseCorrupted("Unknown store version at URL \(storeURL).") + } + return try migrationSteps(fromSourceVersion: sourceVersion, toDestinationVersion: destinationVersion) + } + + private func migrationSteps( + fromSourceVersion sourceVersion: CoreDataMigrationVersion, + toDestinationVersion destinationVersion: CoreDataMigrationVersion + ) throws -> [CoreDataMigrationStep] { + var sourceVersion = sourceVersion + var migrationSteps = [CoreDataMigrationStep]() + + while sourceVersion != destinationVersion, let nextVersion = sourceVersion.nextVersion() { + let migrationStep = try CoreDataMigrationStep(sourceVersion: sourceVersion, destinationVersion: nextVersion) + migrationSteps.append(migrationStep) + + sourceVersion = nextVersion + } + + return migrationSteps + } + + func forceWALCheckpointingForStore(at storeURL: URL) throws { + guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL), + let currentModel = NSManagedObjectModel.compatibleModelForStoreMetadata(metadata) + else { return } + + do { + let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: currentModel) + let options = [NSSQLitePragmasOption: ["journal_mode": "DELETE"]] + let store = try persistentStoreCoordinator.addPersistentStore(at: storeURL, options: options) + try persistentStoreCoordinator.remove(store) + } catch { + throw AppError.databaseCorrupted("Failed to force WAL checkpointing, error: \(error).") + } + } +} + +private extension CoreDataMigrationVersion { + static func compatibleVersionForStoreMetadata(_ metadata: [String: Any]) throws -> CoreDataMigrationVersion? { + let compatibleVersion = try CoreDataMigrationVersion.allCases.first { + let model = try NSManagedObjectModel.managedObjectModel(forResource: $0.rawValue) + return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) + } + return compatibleVersion + } +} diff --git a/EhPanda/Database/Migration/Mappings/Model5toModel6.xcmappingmodel/xcmapping.xml b/EhPanda/Database/Migration/Mappings/Model5toModel6.xcmappingmodel/xcmapping.xml new file mode 100644 index 00000000..95d12a76 --- /dev/null +++ b/EhPanda/Database/Migration/Mappings/Model5toModel6.xcmappingmodel/xcmapping.xml @@ -0,0 +1,343 @@ + + + + + + 134481920 + 521A9C87-4856-47D3-B239-6780806CD202 + 157 + + + + NSPersistenceFrameworkVersion + 1145 + NSStoreModelVersionHashes + + XDDevAttributeMapping + + 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= + + XDDevEntityMapping + + qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= + + XDDevMappingModel + + EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= + + XDDevPropertyMapping + + XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= + + XDDevRelationshipMapping + + akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= + + + NSStoreModelVersionHashesDigest + +Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A== + NSStoreModelVersionHashesVersion + 3 + NSStoreModelVersionIdentifiers + + + + + + + + + EhPanda.GalleryStateMO5toGalleryStateMO6MigrationPolicy + GalleryStateMO + Undefined + 3 + GalleryStateMO + 1 + + + + + + sizeType + + + + EhPanda.GalleryDetailMO5toGalleryDetailMO6MigrationPolicy + GalleryDetailMO + Undefined + 1 + GalleryDetailMO + 1 + + + + + + language + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXxAQb3JpZ2luYWxDb250ZW50c9IfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGNAZIBsQG1AboByQHNAdUB2gHwAfUAAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACDA== + + originalImageURLs + + + + visibility + + + + postedDate + + + + category + + + + previewConfig + + + + setting + + + + gid + + + + jpnTitle + + + + postedDate + + + + EhPanda.GalleryMO5toGalleryMO6MigrationPolicy + GalleryMO + Undefined + 4 + GalleryMO + 1 + + + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= + + coverURL + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= + + parentURL + + + + rating + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXGdsb2JhbEZpbHRlctIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGHAYwBqwGvAbQBwwHHAc8B1AHqAe8AAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACBg== + + watchedFilter + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWnRodW1ibmFpbHPSHyAyM18QHE5TS2V5UGF0aFNwZWNpZmllckV4cHJlc3Npb26jMiQl0h8gNTZeTlNNdXRhYmxlQXJyYXmjNTclV05TQXJyYXnSHyA5Ol8QE05TS2V5UGF0aEV4cHJlc3Npb26kOTskJV8QFE5TRnVuY3Rpb25FeHByZXNzaW9uAAgAEQAaACQAKQAyADcASQBMAFEAUwBgAGYAcQB7AIoAnQCpALAAsgC0ALYAuAC6AM0A1ADfAOEA4wDlAOwA8QD8AQUBHAEgATcBRAFNAVIBXQFfAWEBYwFqAXQBdgF4AXoBhQGKAakBrQGyAcEBxQHNAdIB6AHtAAAAAAAAAgEAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAgQ= + + thumbnailURLs + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXGZhdm9yZWRDb3VudNIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGHAYwBqwGvAbQBwwHHAc8B1AHqAe8AAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACBg== + + favoritedCount + + + + torrentCount + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= + + archiveURL + + + + category + + + + readingProgress + + + + ratingCount + + + + tags + + + + pageCount + + + + token + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWWlzRmF2b3JlZNIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGEAYkBqAGsAbEBwAHEAcwB0QHnAewAAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACAw== + + isFavorited + + + + comments + + + + gid + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= + + coverURL + + + + uploader + + + + EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel + 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lXV94ZF9tb2RlbE5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgQT3gQT1gACBBPaAAIEBAd4AHAAdAB4AHwAgACEAIgAOACMAJAAlACYAJwAoACkAKgArAAkAKQAXAC8AMAAxADIAMwApACkAF18QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABIEE84EE8YABgASAAIEE8oEE9BAAgAWAA4AEgASAAFBTWUVT0wA6ADsADgA8AEEARldOUy5rZXlzWk5TLm9iamVjdHOkAD0APgA/AECABoAHgAiACaQAQgBDAEQARYAKgQEMgQMGgQQ0gCheR2FsbGVyeVN0YXRlTU9fEA9HYWxsZXJ5RGV0YWlsTU9ZR2FsbGVyeU1PWEFwcEVudk1P3xAQAEwATQBOAE8AIQBQAFEAIwBSAFMADgAlAFQAVQAoAFYAVwBYACkAKQAUAFwAXQAxACkAVwBgAD0AVwBjAGQAZV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIAMgDCABIAEgAKADYEBCYAEgAyBAQuABoAMgQEKgAsIEi7IzU9Xb3JkZXJlZNMAOgA7AA4AaQBrAEahAGqADqEAbIAPgCheWERfUFN0ZXJlb3R5cGXZACEAJQBwAA4AKABxACMAVgByAEIAagBXAHYAFwApADEAZQB6XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgAqADoAMgC+AAIAECIAQ0wA6ADsADgB8AIYARqkAfQB+AH8AgACBAIIAgwCEAIWAEYASgBOAFIAVgBaAF4AYgBmpAIcAiACJAIoAiwCMAI0AjgCPgBqAHoAfgCGAIoAkgCaAKYAtgChfEBNYRFBNQ29tcG91bmRJbmRleGVzXxAQWERfUFNLX2VsZW1lbnRJRF8QGVhEUE1VbmlxdWVuZXNzQ29uc3RyYWludHNfEBpYRF9QU0tfdmVyc2lvbkhhc2hNb2RpZmllcl8QGVhEX1BTS19mZXRjaFJlcXVlc3RzQXJyYXlfEBFYRF9QU0tfaXNBYnN0cmFjdF8QD1hEX1BTS191c2VySW5mb18QE1hEX1BTS19jbGFzc01hcHBpbmdfEBZYRF9QU0tfZW50aXR5Q2xhc3NOYW1l3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcApgAXAGwAZQBlAGUAMQBlAK0AfQBlAGUAFwBlVV90eXBlWF9kZWZhdWx0XF9hc3NvY2lhdGlvbltfaXNSZWFkT25seVlfaXNTdGF0aWNZX2lzVW5pcXVlWl9pc0Rlcml2ZWRaX2lzT3JkZXJlZFxfaXNDb21wb3NpdGVXX2lzTGVhZoAAgBuAAIAPCAgICIAdgBEICIAACNIAOwAOALQAtaCAHNIAtwC4ALkAulokY2xhc3NuYW1lWCRjbGFzc2VzXk5TTXV0YWJsZUFycmF5owC5ALsAvFdOU0FycmF5WE5TT2JqZWN00gC3ALgAvgC/XxAQWERVTUxQcm9wZXJ0eUltcKQAwADBAMIAvF8QEFhEVU1MUHJvcGVydHlJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwBsAGUAZQBlADEAZQCtAH4AZQBlABcAZYAAgACAAIAPCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXANQAFwBsAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgCCAAIAPCAgICIAdgBMICIAACNIAOwAOAOIAtaCAHN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwBsAGUAZQBlADEAZQCtAIAAZQBlABcAZYAAgACAAIAPCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAPUAFwBsAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgCOAAIAPCAgICIAdgBUICIAACNIAOwAOAQMAtaCAHN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwBsAGUAZQBlADEAZQCtAIIAZQBlABcAZYAAgCWAAIAPCAgICIAdgBYICIAACAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEXABcAbABlAGUAZQAxAGUArQCDAGUAZQAXAGWAAIAngACADwgICAiAHYAXCAiAAAjTADoAOwAOASUBJgBGoKCAKNIAtwC4ASkBKl8QE05TTXV0YWJsZURpY3Rpb25hcnmjASkBKwC8XE5TRGljdGlvbmFyed8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAS4AFwBsAGUAZQBlADEAZQCtAIQAZQBlABcAZYAAgCqAAIAPCAgICIAdgBgICIAACNYAJQAOACgAVgAhACMBPAE9ABcAZQAXADGAK4AsgAAIgABfEBRYREdlbmVyaWNSZWNvcmRDbGFzc9IAtwC4AUMBRF1YRFVNTENsYXNzSW1wpgFFAUYBRwFIAUkAvF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAUwAFwBsAGUAZQBlADEAZQCtAIUAZQBlABcAZYAAgC6AAIAPCAgICIAdgBkICIAACF8QDy5HYWxsZXJ5U3RhdGVNT9IAtwC4AVsBXF8QElhEVU1MU3RlcmVvdHlwZUltcKcBXQFeAV8BYAFhAWIAvF8QElhEVU1MU3RlcmVvdHlwZUltcF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcNMAOgA7AA4BZAFtAEaoAWUBZgFnAWgBaQFqAWsBbIAxgDKAM4A0gDWANoA3gDioAW4BbwFwAXEBcgFzAXQBdYA5gGSAe4CUgKuAwoDZgPCAKFhjb21tZW50c1p0aHVtYm5haWxzXxAPcmVhZGluZ1Byb2dyZXNzXxAQb3JpZ2luYWxDb250ZW50c1hjb250ZW50c1hwcmV2aWV3c1R0YWdzU2dpZN8QEgCbAJwAnQGAACEAnwCgAYEAIwCeAYIAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlAYoAMQBlAFcAZQGOAWUAZQBlAZIAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICIA7CIAMCIBjgDEICIA6CBL1LwGf0wA6ADsADgGWAZkARqIBlwGYgDyAPaIBmgGbgD6AUoAoXxASWERfUFByb3BTdGVyZW90eXBlXxASWERfUEF0dF9TdGVyZW90eXBl2QAhACUBoAAOACgBoQAjAFYBogFuAZcAVwB2ABcAKQAxAGUBql8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V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IWmR1cGxpY2F0ZXPSADsADguoALWggBzSALcAuAurC6xaWERQTUVudGl0eacLrQuuC68LsAuxC7IAvFpYRFBNRW50aXR5XVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAQC7QLtQu2C7cAIQu4C7kAIwu6C7sADgAlC7wLvQAoAFYAVwu/ACkAKQAUC8MAXQAxACkAVwBgAD4AVwvKC8sAZV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZYAMgQEfgASABIACgQEOgQEJgASADIEBC4AHgAyBAwWBAQ0IEqlMFM3TADoAOwAOC88L0QBGoQBqgA6hC9KBAQ+AKNkAIQAlC9UADgAoC9YAIwBWC9cAQwBqAFcAdgAXACkAMQBlC99fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAQyADoAMgC+AAIAECIEBENMAOgA7AA4L4QvrAEapAH0AfgB/AIAAgQCCAIMAhACFgBGAEoATgBSAFYAWgBeAGIAZqQvsC+0L7gvvC/AL8QvyC/ML9IEBEYEBE4E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AGVIAABljAAAZZQAAGWcAABlpAAAZawAAGW0AABlvAAAZcQAAGXMAABl1AAAZkwAAGbEAABnEAAAZ2AAAGe0AABoKAAAaHgAAGjQAABpzAAAadQAAGncAABp5AAAaewAAGnwAABp9AAAafgAAGn8AABqBAAAagwAAGoQAABqFAAAahwAAGogAABrHAAAayQAAGssAABrNAAAazwAAGtAAABrRAAAa0gAAGtMAABrVAAAa1wAAGtgAABrZAAAa2wAAGtwAABsbAAAbHQAAGx8AABshAAAbIwAAGyQAABslAAAbJgAAGycAABspAAAbKwAAGywAABstAAAbLwAAGzAAABs9AAAbPgAAGz8AABtBAAAbgAAAG4IAABuEAAAbhgAAG4gAABuJAAAbigAAG4sAABuMAAAbjgAAG5AAABuRAAAbkgAAG5QAABuVAAAb1AAAG9YAABvYAAAb2gAAG9wAABvdAAAb3gAAG98AABvgAAAb4gAAG+QAABvlAAAb5gAAG+gAABvpAAAb6gAAHCkAABwrAAAcLQAAHC8AABwxAAAcMgAAHDMAABw0AAAcNQAAHDcAABw5AAAcOgAAHDsAABw9AAAcPgAAHH0AABx/AAAcgQAAHIMAAByFAAAchgAAHIcAAByIAAAciQAAHIsAAByNAAAcjgAAHI8AAByRAAAckgAAHNEAABzTAAAc1QAAHNcAABzZAAAc2gAAHNsAABzcAAAc3QAAHN8AABzhAAAc4gAAHOMAABzlAAAc5gAAHQsAAB0vAAAdVgAAHXoAAB18AAAdfgAAHYAAAB2CAAAdhAAAHYYAAB2HAAAdiQAAHZYAAB2lAAAdpwAAHakAAB2rAAAdrQAAHa8AAB2xAAAdswAAHcIAAB3EAAAdxgAAHcgAAB3KAAAdzAAAHc4AAB3QAAAd0gAAHfIAAB4dAAAeNwAAHlAAAB5qAAAeigAAHq0AAB7sAAAe7gAAHvAAAB7yAAAe9AAAHvUAAB72AAAe9wAAHvgAAB76AAAe/AAAHv0AAB7+AAAfAAAAHwEAAB9AAAAfQgAAH0QAAB9GAAAfSAAAH0kAAB9KAAAfSwAAH0wAAB9OAAAfUAAAH1EAAB9SAAAfVAAAH1UAAB+UAAAflgAAH5gAAB+aAAAfnAAAH50AAB+eAAAfnwAAH6AAAB+iAAAfpAAAH6UAAB+mAAAfqAAAH6kAAB/oAAAf6gAAH+wAAB/uAAAf8AAAH/EAAB/yAAAf8wAAH/QAAB/2AAAf+AAAH/kAAB/6AAAf/AAAH/0AACAAAAAgPwAAIEEAACBDAAAgRQAAIEcAACBIAAAgSQAAIEoAACBLAAAgTQAAIE8AACBQAAAgUQAAIFMAACBUAAAgkwAAIJUAACCXAAAgmQAAIJsAACCcAAAgnQAAIJ4AACCfAAAgoQAAIKMAACCkAAAgpQAAIKcAACCoAAAg5wAAIOkAACDrAAAg7QAAIO8AACDwAAAg8QAAIPIAACDzAAAg9QAAIPcAACD4AAAg+QAAIPsAACD8AAAhBQAAIRMAACEgAAAhLgAAITsAACFOAAAhZQAAIXcAACHCAAAh5QAAIgUAACIlAAAiJwAAIikAACIrAAAiLQAAIi8AACIwAAAiMQAAIjMAACI0AAAiNgAAIjcAACI5AAAiOwAAIjwAACI9AAAiPwAAIkAAACJFAAAiUgAAIlcAACJZAAAiWwAAImAAACJiAAAiZAAAImYAACKLAAAirwAAItYAACL6AAAi/AAAIv4AACMAAAAjAgAAIwQAACMGAAAjBwAAIwkAACMWAAAjJwAAIykAACMrAAAjLQAAIy8AACMxAAAjMwAAIzUAACM3AAAjSAAAI0oAACNMAAAjTgAAI1AAACNSAAAjVAAAI1YAACNYAAAjWgAAI5kAACObAAAjnQAAI58AACOhAAAjogAAI6MAACOkAAAjpQAAI6cAACOpAAAjqgAAI6sAACOtAAAjrgAAI+0AACPvAAAj8QAAI/MAACP1AAAj9gAAI/cAACP4AAAj+QAAI/sAACP9AAAj/gAAI/8AACQBAAAkAgAAJEEAACRDAAAkRQAAJEcAACRJAAAkSgAAJEsAACRMAAAkTQAAJE8AACRRAAAkUgAAJFMAACRVAAAkVgAAJGMAACRkAAAkZQAAJGcAACSmAAAkqAAAJKoAACSsAAAkrgAAJK8AACSwAAAksQAAJLIAACS0AAAktgAAJLcAACS4AAAkugAAJLsAACT6AAAk/AAAJP4AACUAAAAlAgAAJQMAACUEAAAlBQAAJQYAACUIAAAlCgAAJQsAACUMAAAlDgAAJQ8AACVOAAAlUAAAJVIAACVUAAAlVgAAJVcAACVYAAAlWQAAJVoAACVcAAAlXgAAJV8AACVgAAAlYgAAJWMAACWiAAAlpAAAJaYAACWoAAAlqgAAJasAACWsAAAlrQAAJa4AACWwAAAlsgAAJbMAACW0AAAltgAAJbcAACX2AAAl+AAAJfoAACX8AAAl/gAAJf8AACYAAAAmAQAAJgIAACYEAAAmBgAAJgcAACYIAAAmCgAAJgsAACYwAAAmVAAAJnsAACafAAAmoQAAJqMAACalAAAmpwAAJqkAACarAAAmrAAAJq4AACa7AAAmygAAJswAACbOAAAm0AAAJtIAACbUAAAm1gAAJtgAACbnAAAm6QAAJusAACbtAAAm7wAAJvEAACbzAAAm9QAAJvcAACc2AAAnOAAAJzoAACc8AAAnPgAAJz8AACdAAAAnQQAAJ0IAACdEAAAnRgAAJ0cAACdIAAAnSgAAJ0sAACeKAAAnjAAAJ44AACeQAAAnkgAAJ5MAACeUAAAnlQAAJ5YAACeYAAAnmgAAJ5sAACecAAAnngAAJ58AACfeAAAn4AAAJ+IAACfkAAAn5gAAJ+cAACfoAAAn6QAAJ+oAACfsAAAn7gAAJ+8AACfwAAAn8gAAJ/MAACgyAAAoNAAAKDYAACg4AAAoOgAAKDsAACg8AAAoPQAAKD4AAChAAAAoQgAAKEMAAChEAAAoRgAAKEcAACiGAAAoiAAAKIoAACiMAAAojgAAKI8AACiQAAAokQAAKJIAACiUAAAolgAAKJcAACiYAAAomgAAKJsAACjaAAAo3AAAKN4AACjgAAAo4gAAKOMAACjkAAAo5QAAKOYAACjoAAAo6gAAKOsAACjsAAAo7gAAKO8AACkuAAApMAAAKTIAACk0AAApNgAAKTcAACk4AAApOQAAKToAACk8AAApPgAAKT8AAClAAAApQgAAKUMAACmOAAApsQAAKdEAACnxAAAp8wAAKfUAACn3AAAp+QAAKfsAACn8AAAp/QAAKf8AACoAAAAqAgAAKgMAACoFAAAqBwAAKggAACoJAAAqCwAAKgwAACoRAAAqHgAAKiMAAColAAAqJwAAKiwAACouAAAqMAAAKjIAACpXAAAqewAAKqIAACrGAAAqyAAAKsoAACrMAAAqzgAAKtAAACrSAAAq0wAAKtUAACriAAAq8wAAKvUAACr3AAAq+QAAKvsAACr9AAAq/wAAKwEAACsDAAArFAAAKxYAACsYAAArGgAAKxwAACseAAArIAAAKyIAACskAAArJgAAK2UAACtnAAAraQAAK2sAACttAAArbgAAK28AACtwAAArcQAAK3MAACt1AAArdgAAK3cAACt5AAAregAAK7kAACu7AAArvQAAK78AACvBAAArwgAAK8MAACvEAAArxQAAK8cAACvJAAArygAAK8sAACvNAAArzgAALA0AACwPAAAsEQAALBMAACwVAAAsFgAALBcAACwYAAAsGQAALBsAACwdAAAsHgAALB8AACwhAAAsIgAALC8AACwwAAAsMQAALDMAACxyAAAsdAAALHYAACx4AAAsegAALHsAACx8AAAsfQAALH4AACyAAAAsggAALIMAACyEAAAshgAALIcAACzGAAAsyAAALMoAACzMAAAszgAALM8AACzQAAAs0QAALNIAACzUAAAs1gAALNcAACzYAAAs2gAALNsAAC0aAAAtHAAALR4AAC0gAAAtIgAALSMAAC0kAAAtJQAALSYAAC0oAAAtKgAALSsAAC0sAAAtLgAALS8AAC1uAAAtcAAALXIAAC10AAAtdgAALXcAAC14AAAteQAALXoAAC18AAAtfgAALX8AAC2AAAAtggAALYMAAC3CAAAtxAAALcYAAC3IAAAtygAALcsAAC3MAAAtzQAALc4AAC3QAAAt0gAALdMAAC3UAAAt1gAALdcAAC38AAAuIAAALkcAAC5rAAAubQAALm8AAC5xAAAucwAALnUAAC53AAAueAAALnoAAC6HAAAulgAALpgAAC6aAAAunAAALp4AAC6gAAAuogAALqQAAC6zAAAutQAALrcAAC65AAAuuwAALr0AAC6/AAAuwQAALsMAAC8CAAAvBAAALwYAAC8IAAAvCgAALwsAAC8MAAAvDQAALw4AAC8QAAAvEgAALxMAAC8UAAAvFgAALxcAAC8ZAAAvWAAAL1oAAC9cAAAvXgAAL2AAAC9hAAAvYgAAL2MAAC9kAAAvZgAAL2gAAC9pAAAvagAAL2wAAC9tAAAvrAAAL64AAC+wAAAvsgAAL7QAAC+1AAAvtgAAL7cAAC+4AAAvugAAL7wAAC+9AAAvvgAAL8AAAC/BAAAwAAAAMAIAADAEAAAwBgAAMAgAADAJAAAwCgAAMAsAADAMAAAwDgAAMBAAADARAAAwEgAAMBQAADAVAAAwGAAAMFcAADBZAAAwWwAAMF0AADBfAAAwYAAAMGEAADBiAAAwYwAAMGUAADBnAAAwaAAAMGkAADBrAAAwbAAAMKsAADCtAAAwrwAAMLEAADCzAAAwtAAAMLUAADC2AAAwtwAAMLkAADC7AAAwvAAAML0AADC/AAAwwAAAMP8AADEBAAAxAwAAMQUAADEHAAAxCAAAMQkAADEKAAAxCwAAMQ0AADEPAAAxEAAAMREAADETAAAxFAAAMV8AADGCAAAxogAAMcIAADHEAAAxxgAAMcgAADHKAAAxzAAAMc0AADHOAAAx0AAAMdEAADHTAAAx1AAAMdYAADHYAAAx2QAAMdoAADHcAAAx3QAAMeIAADHvAAAx9AAAMfYAADH4AAAx/QAAMf8AADIBAAAyAwAAMigAADJMAAAycwAAMpcAADKZAAAymwAAMp0AADKfAAAyoQAAMqMAADKkAAAypgAAMrMAADLEAAAyxgAAMsgAADLKAAAyzAAAMs4AADLQAAAy0gAAMtQAADLlAAAy5wAAMukAADLrAAAy7QAAMu8AADLxAAAy8wAAMvUAADL3AAAzNgAAMzgAADM6AAAzPAAAMz4AADM/AAAzQAAAM0EAADNCAAAzRAAAM0YAADNHAAAzSAAAM0oAADNLAAAzigAAM4wAADOOAAAzkAAAM5IAADOTAAAzlAAAM5UAADOWAAAzmAAAM5oAADObAAAznAAAM54AADOfAAAz3gAAM+AAADPiAAAz5AAAM+YAADPnAAAz6AAAM+kAADPqAAAz7AAAM+4AADPvAAAz8AAAM/IAADPzAAA0AAAANAEAADQCAAA0BAAANEMAADRFAAA0RwAANEkAADRLAAA0TAAANE0AADROAAA0TwAANFEAADRTAAA0VAAANFUAADRXAAA0WAAANJcAADSZAAA0mwAANJ0AADSfAAA0oAAANKEAADSiAAA0owAANKUAADSnAAA0qAAANKkAADSrAAA0rAAANOsAADTtAAA07wAANPEAADTzAAA09AAANPUAADT2AAA09wAANPkAADT7AAA0/AAANP0AADT/AAA1AAAANT8AADVBAAA1QwAANUUAADVHAAA1SAAANUkAADVKAAA1SwAANU0AADVPAAA1UAAANVEAADVTAAA1VAAANZMAADWVAAA1lwAANZkAADWbAAA1nAAANZ0AADWeAAA1nwAANaEAADWjAAA1pAAANaUAADWnAAA1qAAANc0AADXxAAA2GAAANjwAADY+AAA2QAAANkIAADZEAAA2RgAANkgAADZJAAA2SwAANlgAADZnAAA2aQAANmsAADZtAAA2bwAANnEAADZzAAA2dQAANoQAADaGAAA2iAAANooAADaMAAA2jgAANpAAADaSAAA2lAAANtMAADbVAAA21wAANtkAADbbAAA23AAANt0AADbeAAA23wAANuEAADbjAAA25AAANuUAADbnAAA26AAANycAADcpAAA3KwAANy0AADcvAAA3MAAANzEAADcyAAA3MwAANzUAADc3AAA3OAAANzkAADc7AAA3PAAAN3sAADd9AAA3fwAAN4EAADeDAAA3hAAAN4UAADeGAAA3hwAAN4kAADeLAAA3jAAAN40AADePAAA3kAAAN88AADfRAAA30wAAN9UAADfXAAA32AAAN9kAADfaAAA32wAAN90AADffAAA34AAAN+EAADfjAAA35AAAOCMAADglAAA4JwAAOCkAADgrAAA4LAAAOC0AADguAAA4LwAAODEAADgzAAA4NAAAODUAADg3AAA4OAAAOHcAADh5AAA4ewAAOH0AADh/AAA4gAAAOIEAADiCAAA4gwAAOIUAADiHAAA4iAAAOIkAADiLAAA4jAAAOMsAADjNAAA4zwAAONEAADjTAAA41AAAONUAADjWAAA41wAAONkAADjbAAA43AAAON0AADjfAAA44AAAOSsAADlOAAA5bgAAOY4AADmQAAA5kgAAOZQAADmWAAA5mAAAOZkAADmaAAA5nAAAOZ0AADmfAAA5oAAAOaIAADmkAAA5pQAAOaYAADmoAAA5qQAAOa4AADm7AAA5wAAAOcIAADnEAAA5yQAAOcsAADnNAAA5zwAAOfQAADoYAAA6PwAAOmMAADplAAA6ZwAAOmkAADprAAA6bQAAOm8AADpwAAA6cgAAOn8AADqQAAA6kgAAOpQAADqWAAA6mAAAOpoAADqcAAA6ngAAOqAAADqxAAA6swAAOrUAADq3AAA6uQAAOrsAADq9AAA6vwAAOsEAADrDAAA7AgAAOwQAADsGAAA7CAAAOwoAADsLAAA7DAAAOw0AADsOAAA7EAAAOxIAADsTAAA7FAAAOxYAADsXAAA7VgAAO1gAADtaAAA7XAAAO14AADtfAAA7YAAAO2EAADtiAAA7ZAAAO2YAADtnAAA7aAAAO2oAADtrAAA7qgAAO6wAADuuAAA7sAAAO7IAADuzAAA7tAAAO7UAADu2AAA7uAAAO7oAADu7AAA7vAAAO74AADu/AAA7zAAAO80AADvOAAA70AAAPA8AADwRAAA8EwAAPBUAADwXAAA8GAAAPBkAADwaAAA8GwAAPB0AADwfAAA8IAAAPCEAADwjAAA8JAAAPGMAADxlAAA8ZwAAPGkAADxrAAA8bAAAPG0AADxuAAA8bwAAPHEAADxzAAA8dAAAPHUAADx3AAA8eAAAPLcAADy5AAA8uwAAPL0AADy/AAA8wAAAPMEAADzCAAA8wwAAPMUAADzHAAA8yAAAPMkAADzLAAA8zAAAPQsAAD0NAAA9DwAAPREAAD0TAAA9FAAAPRUAAD0WAAA9FwAAPRkAAD0bAAA9HAAAPR0AAD0fAAA9IAAAPV8AAD1hAAA9YwAAPWUAAD1nAAA9aAAAPWkAAD1qAAA9awAAPW0AAD1vAAA9cAAAPXEAAD1zAAA9dAAAPZkAAD29AAA95AAAPggAAD4KAAA+DAAAPg4AAD4QAAA+EgAAPhQAAD4VAAA+FwAAPiQAAD4zAAA+NQAAPjcAAD45AAA+OwAAPj0AAD4/AAA+QQAAPlAAAD5SAAA+VAAAPlYAAD5YAAA+WgAAPlwAAD5eAAA+YAAAPp8AAD6hAAA+owAAPqUAAD6nAAA+qAAAPqkAAD6qAAA+qwAAPq0AAD6vAAA+sAAAPrEAAD6zAAA+tAAAPvMAAD71AAA+9wAAPvkAAD77AAA+/AAAPv0AAD7+AAA+/wAAPwEAAD8DAAA/BAAAPwUAAD8HAAA/CAAAP0cAAD9JAAA/SwAAP00AAD9PAAA/UAAAP1EAAD9SAAA/UwAAP1UAAD9XAAA/WAAAP1kAAD9bAAA/XAAAP5sAAD+dAAA/nwAAP6EAAD+jAAA/pAAAP6UAAD+mAAA/pwAAP6kAAD+rAAA/rAAAP60AAD+vAAA/sAAAP+8AAD/xAAA/8wAAP/UAAD/3AAA/+AAAP/kAAD/6AAA/+wAAP/0AAD//AABAAAAAQAEAAEADAABABAAAQEMAAEBFAABARwAAQEkAAEBLAABATAAAQE0AAEBOAABATwAAQFEAAEBTAABAVAAAQFUAAEBXAABAWAAAQJcAAECZAABAmwAAQJ0AAECfAABAoAAAQKEAAECiAABAowAAQKUAAECnAABAqAAAQKkAAECrAABArAAAQPcAAEEaAABBOgAAQVoAAEFcAABBXgAAQWAAAEFiAABBZAAAQWUAAEFmAABBaAAAQWkAAEFrAABBbAAAQW4AAEFwAABBcQAAQXIAAEF0AABBdQAAQXoAAEGHAABBjAAAQY4AAEGQAABBlQAAQZcAAEGZAABBmwAAQcAAAEHkAABCCwAAQi8AAEIxAABCMwAAQjUAAEI3AABCOQAAQjsAAEI8AABCPgAAQksAAEJcAABCXgAAQmAAAEJiAABCZAAAQmYAAEJoAABCagAAQmwAAEJ9AABCfwAAQoEAAEKDAABChQAAQocAAEKJAABCiwAAQo0AAEKPAABCzgAAQtAAAELSAABC1AAAQtYAAELXAABC2AAAQtkAAELaAABC3AAAQt4AAELfAABC4AAAQuIAAELjAABDIgAAQyQAAEMmAABDKAAAQyoAAEMrAABDLAAAQy0AAEMuAABDMAAAQzIAAEMzAABDNAAAQzYAAEM3AABDdgAAQ3gAAEN6AABDfAAAQ34AAEN/AABDgAAAQ4EAAEOCAABDhAAAQ4YAAEOHAABDiAAAQ4oAAEOLAABDmAAAQ5kAAEOaAABDnAAAQ9sAAEPdAABD3wAAQ+EAAEPjAABD5AAAQ+UAAEPmAABD5wAAQ+kAAEPrAABD7AAAQ+0AAEPvAABD8AAARC8AAEQxAABEMwAARDUAAEQ3AABEOAAARDkAAEQ6AABEOwAARD0AAEQ/AABEQAAAREEAAERDAABERAAARIMAAESFAABEhwAARIkAAESLAABEjAAARI0AAESOAABEjwAARJEAAESTAABElAAARJUAAESXAABEmAAARNcAAETZAABE2wAARN0AAETfAABE4AAAROEAAETiAABE4wAAROUAAETnAABE6AAAROkAAETrAABE7AAARSsAAEUtAABFLwAARTEAAEUzAABFNAAARTUAAEU2AABFNwAARTkAAEU7AABFPAAART0AAEU/AABFQAAARWUAAEWJAABFsAAARdQAAEXWAABF2AAARdoAAEXcAABF3gAAReAAAEXhAABF4wAARfAAAEX/AABGAQAARgMAAEYFAABGBwAARgkAAEYLAABGDQAARhwAAEYeAABGIAAARiIAAEYkAABGJgAARigAAEYqAABGLAAARmsAAEZtAABGbwAARnEAAEZzAABGdAAARnUAAEZ2AABGdwAARnkAAEZ7AABGfAAARn0AAEZ/AABGgAAARr8AAEbBAABGwwAARsUAAEbHAABGyAAARskAAEbKAABGywAARs0AAEbPAABG0AAARtEAAEbTAABG1AAARxMAAEcVAABHFwAARxkAAEcbAABHHAAARx0AAEceAABHHwAARyEAAEcjAABHJAAARyUAAEcnAABHKAAAR2cAAEdpAABHawAAR20AAEdvAABHcAAAR3EAAEdyAABHcwAAR3UAAEd3AABHeAAAR3kAAEd7AABHfAAAR7sAAEe9AABHvwAAR8EAAEfDAABHxAAAR8UAAEfGAABHxwAAR8kAAEfLAABHzAAAR80AAEfPAABH0AAASA8AAEgRAABIEwAASBUAAEgXAABIGAAASBkAAEgaAABIGwAASB0AAEgfAABIIAAASCEAAEgjAABIJAAASGMAAEhlAABIZwAASGkAAEhrAABIbAAASG0AAEhuAABIbwAASHEAAEhzAABIdAAASHUAAEh3AABIeAAASMMAAEjmAABJBgAASSYAAEkoAABJKgAASSwAAEkuAABJMAAASTEAAEkyAABJNAAASTUAAEk3AABJOAAASToAAEk8AABJPQAAST4AAElAAABJQQAASUYAAElTAABJWAAASVoAAElcAABJYQAASWMAAEllAABJZwAASYwAAEmwAABJ1wAASfsAAEn9AABJ/wAASgEAAEoDAABKBQAASgcAAEoIAABKCgAAShcAAEooAABKKgAASiwAAEouAABKMAAASjIAAEo0AABKNgAASjgAAEpJAABKSwAASk0AAEpPAABKUQAASlMAAEpVAABKVwAASlkAAEpbAABKmgAASpwAAEqeAABKoAAASqIAAEqjAABKpAAASqUAAEqmAABKqAAASqoAAEqrAABKrAAASq4AAEqvAABK7gAASvAAAEryAABK9AAASvYAAEr3AABK+AAASvkAAEr6AABK/AAASv4AAEr/AABLAAAASwIAAEsDAABLQgAAS0QAAEtGAABLSAAAS0oAAEtLAABLTAAAS00AAEtOAABLUAAAS1IAAEtTAABLVAAAS1YAAEtXAABLZAAAS2UAAEtmAABLaAAAS6cAAEupAABLqwAAS60AAEuvAABLsAAAS7EAAEuyAABLswAAS7UAAEu3AABLuAAAS7kAAEu7AABLvAAAS/sAAEv9AABL/wAATAEAAEwDAABMBAAATAUAAEwGAABMBwAATAkAAEwLAABMDAAATA0AAEwPAABMEAAATE8AAExRAABMUwAATFUAAExXAABMWAAATFkAAExaAABMWwAATF0AAExfAABMYAAATGEAAExjAABMZAAATKMAAEylAABMpwAATKkAAEyrAABMrAAATK0AAEyuAABMrwAATLEAAEyzAABMtAAATLUAAEy3AABMuAAATPcAAEz5AABM+wAATP0AAEz/AABNAAAATQEAAE0CAABNAwAATQUAAE0HAABNCAAATQkAAE0LAABNDAAATTEAAE1VAABNfAAATaAAAE2iAABNpAAATaYAAE2oAABNqgAATawAAE2tAABNrwAATbwAAE3LAABNzQAATc8AAE3RAABN0wAATdUAAE3XAABN2QAATegAAE3qAABN7AAATe4AAE3wAABN8gAATfQAAE32AABN+AAATjcAAE45AABOOwAATj0AAE4/AABOQAAATkEAAE5CAABOQwAATkUAAE5HAABOSAAATkkAAE5LAABOTAAATosAAE6NAABOjwAATpEAAE6TAABOlAAATpUAAE6WAABOlwAATpkAAE6bAABOnAAATp0AAE6fAABOoAAATt8AAE7hAABO4wAATuUAAE7nAABO6AAATukAAE7qAABO6wAATu0AAE7vAABO8AAATvEAAE7zAABO9AAATzMAAE81AABPNwAATzkAAE87AABPPAAATz0AAE8+AABPPwAAT0EAAE9DAABPRAAAT0UAAE9HAABPSAAAT4cAAE+JAABPiwAAT40AAE+PAABPkAAAT5EAAE+SAABPkwAAT5UAAE+XAABPmAAAT5kAAE+bAABPnAAAT9sAAE/dAABP3wAAT+EAAE/jAABP5AAAT+UAAE/mAABP5wAAT+kAAE/rAABP7AAAT+0AAE/vAABP8AAAUC8AAFAxAABQMwAAUDUAAFA3AABQOAAAUDkAAFA6AABQOwAAUD0AAFA/AABQQAAAUEEAAFBDAABQRAAAUI8AAFCyAABQ0gAAUPIAAFD0AABQ9gAAUPgAAFD6AABQ/AAAUP0AAFD+AABRAAAAUQEAAFEDAABRBAAAUQYAAFEIAABRCQAAUQoAAFEMAABRDQAAURIAAFEfAABRJAAAUSYAAFEoAABRLQAAUS8AAFExAABRMwAAUVgAAFF8AABRowAAUccAAFHJAABRywAAUc0AAFHPAABR0QAAUdMAAFHUAABR1gAAUeMAAFH0AABR9gAAUfgAAFH6AABR/AAAUf4AAFIAAABSAgAAUgQAAFIVAABSFwAAUhkAAFIbAABSHQAAUh8AAFIhAABSIwAAUiUAAFInAABSZgAAUmgAAFJqAABSbAAAUm4AAFJvAABScAAAUnEAAFJyAABSdAAAUnYAAFJ3AABSeAAAUnoAAFJ7AABSugAAUrwAAFK+AABSwAAAUsIAAFLDAABSxAAAUsUAAFLGAABSyAAAUsoAAFLLAABSzAAAUs4AAFLPAABTDgAAUxAAAFMSAABTFAAAUxYAAFMXAABTGAAAUxkAAFMaAABTHAAAUx4AAFMfAABTIAAAUyIAAFMjAABTMAAAUzEAAFMyAABTNAAAU3MAAFN1AABTdwAAU3kAAFN7AABTfAAAU30AAFN+AABTfwAAU4EAAFODAABThAAAU4UAAFOHAABTiAAAU8cAAFPJAABTywAAU80AAFPPAABT0AAAU9EAAFPSAABT0wAAU9UAAFPXAABT2AAAU9kAAFPbAABT3AAAVBsAAFQdAABUHwAAVCEAAFQjAABUJAAAVCUAAFQmAABUJwAAVCkAAFQrAABULAAAVC0AAFQvAABUMAAAVG8AAFRxAABUcwAAVHUAAFR3AABUeAAAVHkAAFR6AABUewAAVH0AAFR/AABUgAAAVIEAAFSDAABUhAAAVMMAAFTFAABUxwAAVMkAAFTLAABUzAAAVM0AAFTOAABUzwAAVNEAAFTTAABU1AAAVNUAAFTXAABU2AAAVP0AAFUhAABVSAAAVWwAAFVuAABVcAAAVXIAAFV0AABVdgAAVXgAAFV5AABVewAAVYgAAFWXAABVmQAAVZsAAFWdAABVnwAAVaEAAFWjAABVpQAAVbQAAFW3AABVugAAVb0AAFXAAABVwwAAVcYAAFXJAABVywAAVgoAAFYMAABWDwAAVhEAAFYTAABWFAAAVhUAAFYWAABWFwAAVhkAAFYbAABWHAAAVh0AAFYfAABWIAAAViEAAFZgAABWYgAAVmQAAFZmAABWaAAAVmkAAFZqAABWawAAVmwAAFZuAABWcAAAVnEAAFZyAABWdAAAVnUAAFa0AABWtgAAVrgAAFa6AABWvAAAVr0AAFa+AABWvwAAVsAAAFbCAABWxAAAVsUAAFbGAABWyAAAVskAAFcIAABXCgAAVw0AAFcPAABXEQAAVxIAAFcTAABXFAAAVxUAAFcXAABXGQAAVxoAAFcbAABXHQAAVx4AAFchAABXYAAAV2IAAFdkAABXZgAAV2gAAFdpAABXagAAV2sAAFdsAABXbgAAV3AAAFdxAABXcgAAV3QAAFd1AABXtAAAV7YAAFe4AABXugAAV7wAAFe9AABXvgAAV78AAFfAAABXwgAAV8QAAFfFAABXxgAAV8gAAFfJAABYCAAAWAoAAFgMAABYDgAAWBAAAFgRAABYEgAAWBMAAFgUAABYFgAAWBgAAFgZAABYGgAAWBwAAFgdAABYKAAAWDEAAFgyAABYNAAAWD0AAFhIAABYVwAAWGIAAFhwAABYhQAAWJkAAFiwAABYwgAAWQUAAFkpAABZTQAAWXAAAFmXAABZtwAAWd4AAFoFAABaJQAAWkkAAFptAABabwAAWnIAAFp0AABadgAAWngAAFp7AABafgAAWoAAAFqCAABahQAAWocAAFqJAABajAAAWo8AAFqQAABalQAAWqIAAFqlAABapwAAWqoAAFqtAABarwAAWtQAAFr4AABbHwAAW0MAAFtGAABbSAAAW0oAAFtMAABbTgAAW1AAAFtRAABbVAAAW2EAAFt0AABbdgAAW3gAAFt6AABbfAAAW34AAFuAAABbggAAW4QAAFuGAABbmQAAW5wAAFufAABbogAAW6UAAFuoAABbqwAAW64AAFuxAABbtAAAW7YAAFv1AABb9wAAW/oAAFv8AABb/wAAXAAAAFwBAABcAgAAXAMAAFwFAABcBwAAXAgAAFwJAABcCwAAXAwAAFwVAABcFgAAXBgAAFxXAABcWQAAXFsAAFxdAABcYAAAXGEAAFxiAABcYwAAXGQAAFxmAABcaAAAXGkAAFxqAABcbAAAXG0AAFysAABcrgAAXLEAAFyzAABctgAAXLcAAFy4AABcuQAAXLoAAFy8AABcvgAAXL8AAFzAAABcwgAAXMMAAFzMAABczQAAXM8AAF0OAABdEAAAXRIAAF0UAABdFwAAXRgAAF0ZAABdGgAAXRsAAF0dAABdHwAAXSAAAF0hAABdIwAAXSQAAF1jAABdZQAAXWgAAF1qAABdbQAAXW4AAF1vAABdcAAAXXEAAF1zAABddQAAXXYAAF13AABdeQAAXXoAAF2DAABdhAAAXYYAAF3FAABdxwAAXckAAF3LAABdzgAAXc8AAF3QAABd0QAAXdIAAF3UAABd1gAAXdcAAF3YAABd2gAAXdsAAF4aAABeHAAAXh8AAF4hAABeJAAAXiUAAF4mAABeJwAAXigAAF4qAABeLAAAXi0AAF4uAABeMAAAXjEAAF4+AABePwAAXkAAAF5CAABegQAAXoMAAF6FAABehwAAXooAAF6LAABejAAAXo0AAF6OAABekAAAXpIAAF6TAABelAAAXpYAAF6XAABe1gAAXtgAAF7bAABe3QAAXuAAAF7hAABe4gAAXuMAAF7kAABe5gAAXugAAF7pAABe6gAAXuwAAF7tAABfAAAAXw0AAF84AABfOwAAXz4AAF9BAABfRAAAX0cAAF9KAABfTQAAX1AAAF9TAABfVgAAX1kAAF9cAABfXwAAX2IAAF9lAABfaAAAX2sAAF9uAABfcAAAX3MAAF+eAABfoQAAX6QAAF+nAABfqgAAX60AAF+wAABfswAAX7YAAF+5AABfvAAAX78AAF/CAABfxQAAX8gAAF/LAABfzgAAX9EAAF/UAABf1wAAX9oAAF/cAABf5QAAX/IAAF/8AABgAgAAYAsAAGAWAABgIwAAYC8AAGA6AABgRAAAYE8AAGBYAABgYgAAYGwAAGB1AABgfgAAYIkAAGCSAABgmQAAYOQAAGEHAABhJwAAYUcAAGFJAABhSwAAYU0AAGFPAABhUgAAYVMAAGFUAABhVwAAYVgAAGFaAABhWwAAYV0AAGFgAABhYQAAYWIAAGFlAABhZgAAYWsAAGF4AABhfQAAYX8AAGGBAABhhgAAYYkAAGGMAABhjgAAYbMAAGHXAABh/gAAYiIAAGIlAABiJwAAYikAAGIrAABiLQAAYi8AAGIwAABiMwAAYkAAAGJRAABiUwAAYlUAAGJXAABiWQAAYlsAAGJdAABiXwAAYmEAAGJyAABidQAAYngAAGJ7AABifgAAYoEAAGKEAABihwAAYooAAGKMAABiywAAYs0AAGLPAABi0QAAYtQAAGLVAABi1gAAYtcAAGLYAABi2gAAYtwAAGLdAABi3gAAYuAAAGLhAABjIAAAYyIAAGMkAABjJgAAYykAAGMqAABjKwAAYywAAGMtAABjLwAAYzEAAGMyAABjMwAAYzUAAGM2AABjdQAAY3cAAGN6AABjfAAAY38AAGOAAABjgQAAY4IAAGODAABjhQAAY4cAAGOIAABjiQAAY4sAAGOMAABjmQAAY5oAAGObAABjnQAAY9wAAGPeAABj4AAAY+IAAGPlAABj5gAAY+cAAGPoAABj6QAAY+sAAGPtAABj7gAAY+8AAGPxAABj8gAAZDEAAGQzAABkNQAAZDcAAGQ6AABkOwAAZDwAAGQ9AABkPgAAZEAAAGRCAABkQwAAZEQAAGRGAABkRwAAZIYAAGSIAABkigAAZIwAAGSPAABkkAAAZJEAAGSSAABkkwAAZJUAAGSXAABkmAAAZJkAAGSbAABknAAAZNsAAGTdAABk3wAAZOEAAGTkAABk5QAAZOYAAGTnAABk6AAAZOoAAGTsAABk7QAAZO4AAGTwAABk8QAAZTAAAGUyAABlNAAAZTYAAGU5AABlOgAAZTsAAGU8AABlPQAAZT8AAGVBAABlQgAAZUMAAGVFAABlRgAAZWsAAGWPAABltgAAZdoAAGXdAABl3wAAZeEAAGXjAABl5QAAZecAAGXoAABl6wAAZfgAAGYHAABmCQAAZgsAAGYNAABmDwAAZhEAAGYTAABmFQAAZiQAAGYnAABmKgAAZi0AAGYwAABmMwAAZjYAAGY5AABmOwAAZnoAAGZ8AABmfwAAZoEAAGaEAABmhQAAZoYAAGaHAABmiAAAZooAAGaMAABmjQAAZo4AAGaQAABmkQAAZtAAAGbSAABm1AAAZtYAAGbZAABm2gAAZtsAAGbcAABm3QAAZt8AAGbhAABm4gAAZuMAAGblAABm5gAAZyUAAGcnAABnKQAAZysAAGcuAABnLwAAZzAAAGcxAABnMgAAZzQAAGc2AABnNwAAZzgAAGc6AABnOwAAZ3oAAGd8AABnfwAAZ4EAAGeEAABnhQAAZ4YAAGeHAABniAAAZ4oAAGeMAABnjQAAZ44AAGeQAABnkQAAZ9AAAGfSAABn1AAAZ9YAAGfZAABn2gAAZ9sAAGfcAABn3QAAZ98AAGfhAABn4gAAZ+MAAGflAABn5gAAaCUAAGgnAABoKQAAaCsAAGguAABoLwAAaDAAAGgxAABoMgAAaDQAAGg2AABoNwAAaDgAAGg6AABoOwAAaHoAAGh8AABofgAAaIAAAGiDAABohAAAaIUAAGiGAABohwAAaIkAAGiLAABojAAAaI0AAGiPAABokAAAaNsAAGj+AABpHgAAaT4AAGlAAABpQgAAaUQAAGlGAABpSQAAaUoAAGlLAABpTgAAaU8AAGlRAABpUgAAaVQAAGlXAABpWAAAaVkAAGlcAABpXQAAaWIAAGlvAABpdAAAaXYAAGl4AABpfQAAaYAAAGmDAABphQAAaaoAAGnOAABp9QAAahkAAGocAABqHgAAaiAAAGoiAABqJAAAaiYAAGonAABqKgAAajcAAGpIAABqSgAAakwAAGpOAABqUAAAalIAAGpUAABqVgAAalgAAGppAABqbAAAam8AAGpyAABqdQAAangAAGp7AABqfgAAaoEAAGqDAABqwgAAasQAAGrGAABqyAAAassAAGrMAABqzQAAas4AAGrPAABq0QAAatMAAGrUAABq1QAAatcAAGrYAABrFwAAaxkAAGsbAABrHQAAayAAAGshAABrIgAAayMAAGskAABrJgAAaygAAGspAABrKgAAaywAAGstAABrbAAAa24AAGtxAABrcwAAa3YAAGt3AABreAAAa3kAAGt6AABrfAAAa34AAGt/AABrgAAAa4IAAGuDAABrkAAAa5EAAGuSAABrlAAAa9MAAGvVAABr1wAAa9kAAGvcAABr3QAAa94AAGvfAABr4AAAa+IAAGvkAABr5QAAa+YAAGvoAABr6QAAbCgAAGwqAABsLAAAbC4AAGwxAABsMgAAbDMAAGw0AABsNQAAbDcAAGw5AABsOgAAbDsAAGw9AABsPgAAbH0AAGx/AABsgQAAbIMAAGyGAABshwAAbIgAAGyJAABsigAAbIwAAGyOAABsjwAAbJAAAGySAABskwAAbNIAAGzUAABs1gAAbNgAAGzbAABs3AAAbN0AAGzeAABs3wAAbOEAAGzjAABs5AAAbOUAAGznAABs6AAAbScAAG0pAABtKwAAbS0AAG0wAABtMQAAbTIAAG0zAABtNAAAbTYAAG04AABtOQAAbToAAG08AABtPQAAbWIAAG2GAABtrQAAbdEAAG3UAABt1gAAbdgAAG3aAABt3AAAbd4AAG3fAABt4gAAbe8AAG3+AABuAAAAbgIAAG4EAABuBgAAbggAAG4KAABuDAAAbhsAAG4eAABuIQAAbiQAAG4nAABuKgAAbi0AAG4wAABuMgAAbnEAAG5zAABudQAAbncAAG56AABuewAAbnwAAG59AABufgAAboAAAG6CAABugwAAboQAAG6GAABuhwAAbsYAAG7IAABuygAAbswAAG7PAABu0AAAbtEAAG7SAABu0wAAbtUAAG7XAABu2AAAbtkAAG7bAABu3AAAbxsAAG8dAABvHwAAbyEAAG8kAABvJQAAbyYAAG8nAABvKAAAbyoAAG8sAABvLQAAby4AAG8wAABvMQAAb3AAAG9yAABvdAAAb3YAAG95AABvegAAb3sAAG98AABvfQAAb38AAG+BAABvggAAb4MAAG+FAABvhgAAb8UAAG/HAABvyQAAb8sAAG/OAABvzwAAb9AAAG/RAABv0gAAb9QAAG/WAABv1wAAb9gAAG/aAABv2wAAcBoAAHAcAABwHgAAcCAAAHAjAABwJAAAcCUAAHAmAABwJwAAcCkAAHArAABwLAAAcC0AAHAvAABwMAAAcG8AAHBxAABwcwAAcHUAAHB4AABweQAAcHoAAHB7AABwfAAAcH4AAHCAAABwgQAAcIIAAHCEAABwhQAAcNAAAHDzAABxEwAAcTMAAHE1AABxNwAAcTkAAHE7AABxPgAAcT8AAHFAAABxQwAAcUQAAHFGAABxRwAAcUkAAHFMAABxTQAAcU4AAHFRAABxUgAAcVsAAHFoAABxbQAAcW8AAHFxAABxdgAAcXkAAHF8AABxfgAAcaMAAHHHAABx7gAAchIAAHIVAAByFwAAchkAAHIbAAByHQAAch8AAHIgAAByIwAAcjAAAHJBAAByQwAAckUAAHJHAABySQAAcksAAHJNAAByTwAAclEAAHJiAAByZQAAcmgAAHJrAABybgAAcnEAAHJ0AABydwAAcnoAAHJ8AAByuwAAcr0AAHK/AABywQAAcsQAAHLFAAByxgAAcscAAHLIAAByygAAcswAAHLNAAByzgAActAAAHLRAABzEAAAcxIAAHMUAABzFgAAcxkAAHMaAABzGwAAcxwAAHMdAABzHwAAcyEAAHMiAABzIwAAcyUAAHMmAABzZQAAc2cAAHNqAABzbAAAc28AAHNwAABzcQAAc3IAAHNzAABzdQAAc3cAAHN4AABzeQAAc3sAAHN8AABziQAAc4oAAHOLAABzjQAAc8wAAHPOAABz0AAAc9IAAHPVAABz1gAAc9cAAHPYAABz2QA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AlR8AAJUgAACVRQAAlWkAAJWQAACVtAAAlbcAAJW5AACVuwAAlb0AAJW/AACVwQAAlcIAAJXFAACV0gAAleEAAJXjAACV5QAAlecAAJXpAACV6wAAle0AAJXvAACV/gAAlgEAAJYEAACWBwAAlgoAAJYNAACWEAAAlhMAAJYVAACWVAAAllYAAJZYAACWWgAAll0AAJZeAACWXwAAlmAAAJZhAACWYwAAlmUAAJZmAACWZwAAlmkAAJZqAACWqQAAlqsAAJatAACWrwAAlrIAAJazAACWtAAAlrUAAJa2AACWuAAAlroAAJa7AACWvAAAlr4AAJa/AACW/gAAlwAAAJcCAACXBAAAlwcAAJcIAACXCQAAlwoAAJcLAACXDQAAlw8AAJcQAACXEQAAlxMAAJcUAACXUwAAl1UAAJdXAACXWQAAl1wAAJddAACXXgAAl18AAJdgAACXYgAAl2QAAJdlAACXZgAAl2gAAJdpAACXqAAAl6oAAJesAACXrgAAl7EAAJeyAACXswAAl7QAAJe1AACXtwAAl7kAAJe6AACXuwAAl70AAJe+AACX/QAAl/8AAJgBAACYAwAAmAYAAJgHAACYCAAAmAkAAJgKAACYDAAAmA4AAJgPAACYEAAAmBIAAJgTAACYUgAAmFQAAJhWAACYWAAAmFsAAJhcAACYXQAAmF4AAJhfAACYYQAAmGMAAJhkAACYZQAAmGcAAJhoAACYswAAmNYAAJj2AACZFgAAmRgAAJkaAACZHAAAmR4AAJkhAACZIgAAmSMAAJkmAACZJwAAmSkAAJkqAACZLAAAmS8AAJkwAACZMQAAmTQAAJk1AACZOgAAmUcAAJlMAACZTgAAmVAAAJlVAACZWAAAmVsAAJldAACZggAAmaYAAJnNAACZ8QAAmfQAAJn2AACZ+AAAmfoAAJn8AACZ/gAAmf8AAJoCAACaDwAAmiAAAJoiAACaJAAAmiYAAJooAACaKgAAmiwAAJouAACaMAAAmkEAAJpEAACaRwAAmkoAAJpNAACaUAAAmlMAAJpWAACaWQAAmlsAAJqaAACanAAAmp4AAJqgAACaowAAmqQAAJqlAACapgAAmqcAAJqpAACaqwAAmqwAAJqtAACarwAAmrAAAJrvAACa8QAAmvMAAJr1AACa+AAAmvkAAJr6AACa+wAAmvwAAJr+AACbAAAAmwEAAJsCAACbBAAAmwUAAJtEAACbRgAAm0kAAJtLAACbTgAAm08AAJtQAACbUQAAm1IAAJtUAACbVgAAm1cAAJtYAACbWgAAm1sAAJtoAACbaQAAm2oAAJtsAACbqwAAm60AAJuvAACbsQAAm7QAAJu1AACbtgAAm7cAAJu4AACbugAAm7wAAJu9AACbvgAAm8AAAJvBAACcAAAAnAIAAJwEAACcBgAAnAkAAJwKAACcCwAAnAwAAJwNAACcDwAAnBEAAJwSAACcEwAAnBUAAJwWAACcVQAAnFcAAJxZAACcWwAAnF4AAJxfAACcYAAAnGEAAJxiAACcZAAAnGYAAJxnAACcaAAAnGoAAJxrAACcqgAAnKwAAJyuAACcsAAAnLMAAJy0AACctQAAnLYAAJy3AACcuQAAnLsAAJy8AACcvQAAnL8AAJzAAACc/wAAnQEAAJ0DAACdBQAAnQgAAJ0JAACdCgAAnQsAAJ0MAACdDgAAnRAAAJ0RAACdEgAAnRQAAJ0VAACdOgAAnV4AAJ2FAACdqQAAnawAAJ2uAACdsAAAnbIAAJ20AACdtgAAnbcAAJ26AACdxwAAndYAAJ3YAACd2gAAndwAAJ3eAACd4AAAneIAAJ3kAACd8wAAnfYAAJ35AACd/AAAnf8AAJ4CAACeBQAAnggAAJ4KAACeSQAAnksAAJ5NAACeTwAAnlIAAJ5TAACeVAAAnlUAAJ5WAACeWAAAnloAAJ5bAACeXAAAnl4AAJ5fAACengAAnqAAAJ6iAACepAAAnqcAAJ6oAACeqQAAnqoAAJ6rAACerQAAnq8AAJ6wAACesQAAnrMAAJ60AACe8wAAnvUAAJ73AACe+QAAnvwAAJ79AACe/gAAnv8AAJ8AAACfAgAAnwQAAJ8FAACfBgAAnwgAAJ8JAACfSAAAn0oAAJ9MAACfTgAAn1EAAJ9SAACfUwAAn1QAAJ9VAACfVwAAn1kAAJ9aAACfWwAAn10AAJ9eAACfnQAAn58AAJ+hAACfowAAn6YAAJ+nAACfqAAAn6kAAJ+qAACfrAAAn64AAJ+vAACfsAAAn7IAAJ+zAACf8gAAn/QAAJ/2AACf+AAAn/sAAJ/8AACf/QAAn/4AAJ//AACgAQAAoAMAAKAEAACgBQAAoAcAAKAIAACgRwAAoEkAAKBLAACgTQAAoFAAAKBRAACgUgAAoFMAAKBUAACgVgAAoFgAAKBZAACgWgAAoFwAAKBdAACgqAAAoMsAAKDrAAChCwAAoQ0AAKEPAAChEQAAoRMAAKEWAAChFwAAoRgAAKEbAAChHAAAoR4AAKEfAAChIQAAoSQAAKElAAChJgAAoSkAAKEqAAChLwAAoTwAAKFBAAChQwAAoUUAAKFKAAChTQAAoVAAAKFSAAChdwAAoZsAAKHCAACh5gAAoekAAKHrAACh7QAAoe8AAKHxAACh8wAAofQAAKH3AACiBAAAohUAAKIXAACiGQAAohsAAKIdAACiHwAAoiEAAKIjAACiJQAAojYAAKI5AACiPAAAoj8AAKJCAACiRQAAokgAAKJLAACiTgAAolAAAKKPAACikQAAopMAAKKVAACimAAAopkAAKKaAACimwAAopwAAKKeAACioAAAoqEAAKKiAACipAAAoqUAAKLkAACi5gAAougAAKLqAACi7QAAou4AAKLvAACi8AAAovEAAKLzAACi9QAAovYAAKL3AACi+QAAovoAAKM5AACjOwAAoz4AAKNAAACjQwAAo0QAAKNFAACjRgAAo0cAAKNJAACjSwAAo0wAAKNNAACjTwAAo1AAAKNdAACjXgAAo18AAKNhAACjoAAAo6IAAKOkAACjpgAAo6kAAKOqAACjqwAAo6wAAKOtAACjrwAAo7EAAKOyAACjswAAo7UAAKO2AACj9QAAo/cAAKP5AACj+wAAo/4AAKP/AACkAAAApAEAAKQCAACkBAAApAYAAKQHAACkCAAApAoAAKQLAACkSgAApEwAAKROAACkUAAApFMAAKRUAACkVQAApFYAAKRXAACkWQAApFsAAKRcAACkXQAApF8AAKRgAACknwAApKEAAKSjAACkpQAApKgAAKSpAACkqgAApKsAAKSsAACkrgAApLAAAKSxAACksgAApLQAAKS1AACk9AAApPYAAKT4AACk+gAApP0AAKT+AACk/wAApQAAAKUBAAClAwAApQUAAKUGAAClBwAApQkAAKUKAAClLwAApVMAAKV6AAClngAApaEAAKWjAAClpQAApacAAKWpAAClqwAApawAAKWvAAClvAAApcsAAKXNAAClzwAApdEAAKXTAACl1QAApdcAAKXZAACl6AAApesAAKXuAACl8QAApfQAAKX3AACl+gAApf0AAKX/AACmPgAApkAAAKZCAACmRAAApkcAAKZIAACmSQAApkoAAKZLAACmTQAApk8AAKZQAACmUQAAplMAAKZUAACmkwAAppUAAKaXAACmmQAAppwAAKadAACmngAApp8AAKagAACmogAApqQAAKalAACmpgAApqgAAKapAACm6AAApuoAAKbsAACm7gAApvEAAKbyAACm8wAApvQAAKb1AACm9wAApvkAAKb6AACm+wAApv0AAKb+AACnPQAApz8AAKdCAACnRAAAp0cAAKdIAACnSQAAp0oAAKdLAACnTQAAp08AAKdQAACnUQAAp1MAAKdUAACnkwAAp5UAAKeXAACnmQAAp5wAAKedAACnngAAp58AAKegAACnogAAp6QAAKelAACnpgAAp6gAAKepAACn6AAAp+oAAKfsAACn7gAAp/EAAKfyAACn8wAAp/QAAKf1AACn9wAAp/kAAKf6AACn+wAAp/0AAKf+AACoPQAAqD8AAKhBAACoQwAAqEYAAKhHAACoSAAAqEkAAKhKAACoTAAAqE4AAKhPAACoUAAAqFIAAKhTAACongAAqMEAAKjhAACpAQAAqQMAAKkFAACpBwAAqQkAAKkMAACpDQAAqQ4AAKkRAACpEgAAqRQAAKkVAACpFwAAqRoAAKkbAACpHAAAqR8AAKkgAACpJQAAqTIAAKk3AACpOQAAqTsAAKlAAACpQwAAqUYAAKlIAACpbQAAqZEAAKm4AACp3AAAqd8AAKnhAACp4wAAqeUAAKnnAACp6QAAqeoAAKntAACp+gAAqgsAAKoNAACqDwAAqhEAAKoTAACqFQAAqhcAAKoZAACqGwAAqiwAAKovAACqMgAAqjUAAKo4AACqOwAAqj4AAKpBAACqRAAAqkYAAKqFAACqhwAAqokAAKqLAACqjgAAqo8AAKqQAACqkQAAqpIAAKqUAACqlgAAqpcAAKqYAACqmgAAqpsAAKraAACq3AAAqt4AAKrgAACq4wAAquQAAKrlAACq5gAAqucAAKrpAACq6wAAquwAAKrtAACq7wAAqvAAAKsvAACrMQAAqzQAAKs2AACrOQAAqzoAAKs7AACrPAAAqz0AAKs/AACrQQAAq0IAAKtDAACrRQAAq0YAAKtTAACrVAAAq1UAAKtXAACrlgAAq5gAAKuaAACrnAAAq58AAKugAACroQAAq6IAAKujAACrpQAAq6cAAKuoAACrqQAAq6sAAKusAACr6wAAq+0AAKvvAACr8QAAq/QAAKv1AACr9gAAq/cAAKv4AACr+gAAq/wAAKv9AACr/gAArAAAAKwBAACsQAAArEIAAKxEAACsRgAArEkAAKxKAACsSwAArEwAAKxNAACsTwAArFEAAKxSAACsUwAArFUAAKxWAACslQAArJcAAKyZAACsmwAArJ4AAKyfAACsoAAArKEAAKyiAACspAAArKYAAKynAACsqAAArKoAAKyrAACs6gAArOwAAKzuAACs8AAArPMAAKz0AACs9QAArPYAAKz3AACs+QAArPsAAKz8AACs/QAArP8AAK0AAACtJQAArUkAAK1wAACtlAAArZcAAK2ZAACtmwAArZ0AAK2fAACtoQAAraIAAK2lAACtsgAArcEAAK3DAACtxQAArccAAK3JAACtywAArc0AAK3PAACt3gAAreEAAK3kAACt5wAAreoAAK3tAACt8AAArfMAAK31AACuNAAArjYAAK45AACuOwAArj4AAK4/AACuQAAArkEAAK5CAACuRAAArkYAAK5HAACuSAAArkoAAK5LAACuTgAAro0AAK6PAACukQAArpMAAK6WAACulwAArpgAAK6ZAACumgAArpwAAK6eAACunwAArqAAAK6iAACuowAAruIAAK7kAACu5gAArugAAK7rAACu7AAAru0AAK7uAACu7wAArvEAAK7zAACu9AAArvUAAK73AACu+AAArzcAAK85AACvPAAArz4AAK9BAACvQgAAr0MAAK9EAACvRQAAr0cAAK9JAACvSgAAr0sAAK9NAACvTgAAr1EAAK+QAACvkgAAr5QAAK+WAACvmQAAr5oAAK+bAACvnAAAr50AAK+fAACvoQAAr6IAAK+jAACvpQAAr6YAAK/lAACv5wAAr+kAAK/rAACv7gAAr+8AAK/wAACv8QAAr/IAAK/0AACv9gAAr/cAAK/4AACv+gAAr/sAALA6AACwPAAAsD4AALBAAACwQwAAsEQAALBFAACwRgAAsEcAALBJAACwSwAAsEwAALBNAACwTwAAsFAAALCbAACwvgAAsN4AALD+AACxAAAAsQIAALEEAACxBgAAsQkAALEKAACxCwAAsQ4AALEPAACxEQAAsRIAALEUAACxFwAAsRgAALEZAACxHAAAsR0AALEiAACxLwAAsTQAALE2AACxOAAAsT0AALFAAACxQwAAsUUAALFqAACxjgAAsbUAALHZAACx3AAAsd4AALHgAACx4gAAseQAALHmAACx5wAAseoAALH3AACyCAAAsgoAALIMAACyDgAAshAAALISAACyFAAAshYAALIYAACyKQAAsiwAALIvAACyMgAAsjUAALI4AACyOwAAsj4AALJBAACyQwAAsoIAALKEAACyhgAAsogAALKLAACyjAAAso0AALKOAACyjwAAspEAALKTAACylAAAspUAALKXAACymAAAstcAALLZAACy2wAAst0AALLgAACy4QAAsuIAALLjAACy5AAAsuYAALLoAACy6QAAsuoAALLsAACy7QAAsywAALMuAACzMQAAszMAALM2AACzNwAAszgAALM5AACzOgAAszwAALM+AACzPwAAs0AAALNCAACzQwAAs1AAALNRAACzUgAAs1QAALOTAACzlQAAs5cAALOZAACznAAAs50AALOeAACznwAAs6AAALOiAACzpAAAs6UAALOmAACzqAAAs6kAALPoAACz6gAAs+wAALPuAACz8QAAs/IAALPzAACz9AAAs/UAALP3AACz+QAAs/oAALP7AACz/QAAs/4AALQ9AAC0PwAAtEEAALRDAAC0RgAAtEcAALRIAAC0SQAAtEoAALRMAAC0TgAAtE8AALRQAAC0UgAAtFMAALSSAAC0lAAAtJYAALSYAAC0mwAAtJwAALSdAAC0ngAAtJ8AALShAAC0owAAtKQAALSlAAC0pwAAtKgAALTnAAC06QAAtOsAALTtAAC08AAAtPEAALTyAAC08wAAtPQAALT2AAC0+AAAtPkAALT6AAC0/AAAtP0AALUiAAC1RgAAtW0AALWRAAC1lAAAtZYAALWYAAC1mgAAtZwAALWeAAC1nwAAtaIAALWvAAC1vgAAtcAAALXCAAC1xAAAtcYAALXIAAC1ygAAtcwAALXbAAC13gAAteEAALXkAAC15wAAteoAALXtAAC18AAAtfIAALYxAAC2MwAAtjUAALY3AAC2OgAAtjsAALY8AAC2PQAAtj4AALZAAAC2QgAAtkMAALZEAAC2RgAAtkcAALaGAAC2iAAAtooAALaMAAC2jwAAtpAAALaRAAC2kgAAtpMAALaVAAC2lwAAtpgAALaZAAC2mwAAtpwAALbbAAC23QAAtt8AALbhAAC25AAAtuUAALbmAAC25wAAtugAALbqAAC27AAAtu0AALbuAAC28AAAtvEAALcwAAC3MgAAtzQAALc2AAC3OQAAtzoAALc7AAC3PAAAtz0AALc/AAC3QQAAt0IAALdDAAC3RQAAt0YAALeFAAC3hwAAt4kAALeLAAC3jgAAt48AALeQAAC3kQAAt5IAALeUAAC3lgAAt5cAALeYAAC3mgAAt5sAALfaAAC33AAAt94AALfgAAC34wAAt+QAALflAAC35gAAt+cAALfpAAC36wAAt+wAALftAAC37wAAt/AAALgvAAC4MQAAuDMAALg1AAC4OAAAuDkAALg6AAC4OwAAuDwAALg+AAC4QAAAuEEAALhCAAC4RAAAuEUAALiQAAC4swAAuNMAALjzAAC49QAAuPcAALj5AAC4+wAAuP4AALj/AAC5AAAAuQMAALkEAAC5BgAAuQcAALkJAAC5DAAAuQ0AALkOAAC5EQAAuRIAALkXAAC5JAAAuSkAALkrAAC5LQAAuTIAALk1AAC5OAAAuToAALlfAAC5gwAAuaoAALnOAAC50QAAudMAALnVAAC51wAAudkAALnbAAC53AAAud8AALnsAAC5/QAAuf8AALoBAAC6AwAAugUAALoHAAC6CQAAugsAALoNAAC6HgAAuiEAALokAAC6JwAAuioAALotAAC6MAAAujMAALo2AAC6OAAAuncAALp5AAC6ewAAun0AALqAAAC6gQAAuoIAALqDAAC6hAAAuoYAALqIAAC6iQAAuooAALqMAAC6jQAAuswAALrOAAC60AAAutIAALrVAAC61gAAutcAALrYAAC62QAAutsAALrdAAC63gAAut8AALrhAAC64gAAuyEAALsjAAC7JgAAuygAALsrAAC7LAAAuy0AALsuAAC7LwAAuzEAALszAAC7NAAAuzUAALs3AAC7OAAAu0UAALtGAAC7RwAAu0kAALuIAAC7igAAu4wAALuOAAC7kQAAu5IAALuTAAC7lAAAu5UAALuXAAC7mQAAu5oAALubAAC7nQAAu54AALvdAAC73wAAu+EAALvjAAC75gAAu+cAALvoAAC76QAAu+oAALvsAAC77gAAu+8AALvwAAC78gAAu/MAALwyAAC8NAAAvDYAALw4AAC8OwAAvDwAALw9AAC8PgAAvD8AALxBAAC8QwA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AzN4AAM0DAADNJwAAzU4AAM1yAADNdQAAzXcAAM15AADNewAAzX0AAM1/AADNgAAAzYMAAM2QAADNnwAAzaEAAM2jAADNpQAAzacAAM2pAADNqwAAza0AAM28AADNvwAAzcIAAM3FAADNyAAAzcsAAM3OAADN0QAAzdMAAM4SAADOFAAAzhYAAM4YAADOGwAAzhwAAM4dAADOHgAAzh8AAM4hAADOIwAAziQAAM4lAADOJwAAzigAAM5nAADOaQAAzmsAAM5tAADOcAAAznEAAM5yAADOcwAAznQAAM52AADOeAAAznkAAM56AADOfAAAzn0AAM68AADOvgAAzsAAAM7CAADOxQAAzsYAAM7HAADOyAAAzskAAM7LAADOzQAAzs4AAM7PAADO0QAAztIAAM8RAADPEwAAzxYAAM8YAADPGwAAzxwAAM8dAADPHgAAzx8AAM8hAADPIwAAzyQAAM8lAADPJwAAzygAAM9nAADPaQAAz2sAAM9tAADPcAAAz3EAAM9yAADPcwAAz3QAAM92AADPeAAAz3kAAM96AADPfAAAz30AAM+8AADPvgAAz8AAAM/CAADPxQAAz8YAAM/HAADPyAAAz8kAAM/LAADPzQAAz84AAM/PAADP0QAAz9IAANARAADQEwAA0BUAANAXAADQGgAA0BsAANAcAADQHQAA0B4AANAgAADQIgAA0CMAANAkAADQJgAA0CcAANByAADQlQAA0LUAANDVAADQ1wAA0NkAANDbAADQ3QAA0OAAANDhAADQ4gAA0OUAANDmAADQ6AAA0OkAANDrAADQ7gAA0O8AANDwAADQ8wAA0PQAAND5AADRBgAA0QsAANENAADRDwAA0RQAANEXAADRGgAA0RwAANFBAADRZQAA0YwAANGwAADRswAA0bUAANG3AADRuQAA0bsAANG9AADRvgAA0cEAANHOAADR3wAA0eEAANHjAADR5QAA0ecAANHpAADR6wAA0e0AANHvAADSAAAA0gMAANIGAADSCQAA0gwAANIPAADSEgAA0hUAANIYAADSGgAA0lkAANJbAADSXQAA0l8AANJiAADSYwAA0mQAANJlAADSZgAA0mgAANJqAADSawAA0mwAANJuAADSbwAA0q4AANKwAADSsgAA0rQAANK3AADSuAAA0rkAANK6AADSuwAA0r0AANK/AADSwAAA0sEAANLDAADSxAAA0wMAANMFAADTCAAA0woAANMNAADTDgAA0w8AANMQAADTEQAA0xMAANMVAADTFgAA0xcAANMZAADTGgAA0ycAANMoAADTKQAA0ysAANNqAADTbAAA024AANNwAADTcwAA03QAANN1AADTdgAA03cAANN5AADTewAA03wAANN9AADTfwAA04AAANO/AADTwQAA08MAANPFAADTyAAA08kAANPKAADTywAA08wAANPOAADT0AAA09EAANPSAADT1AAA09UAANQUAADUFgAA1BgAANQaAADUHQAA1B4AANQfAADUIAAA1CEAANQjAADUJQAA1CYAANQnAADUKQAA1CoAANRpAADUawAA1G0AANRvAADUcgAA1HMAANR0AADUdQAA1HYAANR4AADUegAA1HsAANR8AADUfgAA1H8AANS+AADUwAAA1MIAANTEAADUxwAA1MgAANTJAADUygAA1MsAANTNAADUzwAA1NAAANTRAADU0wAA1NQAANT5AADVHQAA1UQAANVoAADVawAA1W0AANVvAADVcQAA1XMAANV1AADVdgAA1XkAANWGAADVlQAA1ZcAANWZAADVmwAA1Z0AANWfAADVoQAA1aMAANWyAADVtQAA1bgAANW7AADVvgAA1cEAANXEAADVxwAA1ckAANYIAADWCgAA1g0AANYPAADWEgAA1hMAANYUAADWFQAA1hYAANYYAADWGgAA1hsAANYcAADWHgAA1h8AANZeAADWYAAA1mIAANZkAADWZwAA1mgAANZpAADWagAA1msAANZtAADWbwAA1nAAANZxAADWcwAA1nQAANazAADWtQAA1rcAANa5AADWvAAA1r0AANa+AADWvwAA1sAAANbCAADWxAAA1sUAANbGAADWyAAA1skAANcIAADXCgAA1w0AANcPAADXEgAA1xMAANcUAADXFQAA1xYAANcYAADXGgAA1xsAANccAADXHgAA1x8AANdeAADXYAAA12IAANdkAADXZwAA12gAANdpAADXagAA12sAANdtAADXbwAA13AAANdxAADXcwAA13QAANezAADXtQAA17cAANe5AADXvAAA170AANe+AADXvwAA18AAANfCAADXxAAA18UAANfGAADXyAAA18kAANgIAADYCgAA2AwAANgOAADYEQAA2BIAANgTAADYFAAA2BUAANgXAADYGQAA2BoAANgbAADYHQAA2B4AANhpAADYjAAA2KwAANjMAADYzgAA2NAAANjSAADY1AAA2NcAANjYAADY2QAA2NwAANjdAADY3wAA2OAAANjiAADY5QAA2OYAANjnAADY6gAA2OsAANjwAADY/QAA2QIAANkEAADZBgAA2QsAANkOAADZEQAA2RMAANk4AADZXAAA2YMAANmnAADZqgAA2awAANmuAADZsAAA2bIAANm0AADZtQAA2bgAANnFAADZ1gAA2dgAANnaAADZ3AAA2d4AANngAADZ4gAA2eQAANnmAADZ9wAA2foAANn9AADaAAAA2gMAANoGAADaCQAA2gwAANoPAADaEQAA2lAAANpSAADaVAAA2lYAANpZAADaWgAA2lsAANpcAADaXQAA2l8AANphAADaYgAA2mMAANplAADaZgAA2qUAANqnAADaqQAA2qsAANquAADarwAA2rAAANqxAADasgAA2rQAANq2AADatwAA2rgAANq6AADauwAA2voAANr8AADa/wAA2wEAANsEAADbBQAA2wYAANsHAADbCAAA2woAANsMAADbDQAA2w4AANsQAADbEQAA2x4AANsfAADbIAAA2yIAANthAADbYwAA22UAANtnAADbagAA22sAANtsAADbbQAA224AANtwAADbcgAA23MAANt0AADbdgAA23cAANu2AADbuAAA27oAANu8AADbvwAA28AAANvBAADbwgAA28MAANvFAADbxwAA28gAANvJAADbywAA28wAANwLAADcDQAA3A8AANwRAADcFAAA3BUAANwWAADcFwAA3BgAANwaAADcHAAA3B0AANweAADcIAAA3CEAANxgAADcYgAA3GQAANxmAADcaQAA3GoAANxrAADcbAAA3G0AANxvAADccQAA3HIAANxzAADcdQAA3HYAANy1AADctwAA3LkAANy7AADcvgAA3L8AANzAAADcwQAA3MIAANzEAADcxgAA3McAANzIAADcygAA3MsAANzwAADdFAAA3TsAAN1fAADdYgAA3WQAAN1mAADdaAAA3WoAAN1sAADdbQAA3XAAAN19AADdjAAA3Y4AAN2QAADdkgAA3ZQAAN2WAADdmAAA3ZoAAN2pAADdrAAA3a8AAN2yAADdtQAA3bgAAN27AADdvgAA3cAAAN3/AADeAQAA3gQAAN4GAADeCQAA3goAAN4LAADeDAAA3g0AAN4PAADeEQAA3hIAAN4TAADeFQAA3hYAAN5VAADeVwAA3lkAAN5bAADeXgAA3l8AAN5gAADeYQAA3mIAAN5kAADeZgAA3mcAAN5oAADeagAA3msAAN6qAADerAAA3q4AAN6wAADeswAA3rQAAN61AADetgAA3rcAAN65AADeuwAA3rwAAN69AADevwAA3sAAAN7/AADfAQAA3wQAAN8GAADfCQAA3woAAN8LAADfDAAA3w0AAN8PAADfEQAA3xIAAN8TAADfFQAA3xYAAN9VAADfVwAA31kAAN9bAADfXgAA318AAN9gAADfYQAA32IAAN9kAADfZgAA32cAAN9oAADfagAA32sAAN+qAADfrAAA364AAN+wAADfswAA37QAAN+1AADftgAA37cAAN+5AADfuwAA37wAAN+9AADfvwAA38AAAN//AADgAQAA4AMAAOAFAADgCAAA4AkAAOAKAADgCwAA4AwAAOAOAADgEAAA4BEAAOASAADgFAAA4BUAAOBgAADggwAA4KMAAODDAADgxQAA4McAAODJAADgywAA4M4AAODPAADg0AAA4NMAAODUAADg1gAA4NcAAODZAADg3AAA4N0AAODeAADg4QAA4OIAAODnAADg9AAA4PkAAOD7AADg/QAA4QIAAOEFAADhCAAA4QoAAOEvAADhUwAA4XoAAOGeAADhoQAA4aMAAOGlAADhpwAA4akAAOGrAADhrAAA4a8AAOG8AADhzQAA4c8AAOHRAADh0wAA4dUAAOHXAADh2QAA4dsAAOHdAADh7gAA4fEAAOH0AADh9wAA4foAAOH9AADiAAAA4gMAAOIGAADiCAAA4kcAAOJJAADiSwAA4k0AAOJQAADiUQAA4lIAAOJTAADiVAAA4lYAAOJYAADiWQAA4loAAOJcAADiXQAA4pwAAOKeAADioAAA4qIAAOKlAADipgAA4qcAAOKoAADiqQAA4qsAAOKtAADirgAA4q8AAOKxAADisgAA4vEAAOLzAADi9gAA4vgAAOL7AADi/AAA4v0AAOL+AADi/wAA4wEAAOMDAADjBAAA4wUAAOMHAADjCAAA4xUAAOMWAADjFwAA4xkAAONYAADjWgAA41wAAONeAADjYQAA42IAAONjAADjZAAA42UAAONnAADjaQAA42oAAONrAADjbQAA424AAOOtAADjrwAA47EAAOOzAADjtgAA47cAAOO4AADjuQAA47oAAOO8AADjvgAA478AAOPAAADjwgAA48MAAOQCAADkBAAA5AYAAOQIAADkCwAA5AwAAOQNAADkDgAA5A8AAOQRAADkEwAA5BQAAOQVAADkFwAA5BgAAORXAADkWQAA5FsAAORdAADkYAAA5GEAAORiAADkYwAA5GQAAORmAADkaAAA5GkAAORqAADkbAAA5G0AAOSsAADkrgAA5LAAAOSyAADktQAA5LYAAOS3AADkuAAA5LkAAOS7AADkvQAA5L4AAOS/AADkwQAA5MIAAOTnAADlCwAA5TIAAOVWAADlWQAA5VsAAOVdAADlXwAA5WEAAOVjAADlZAAA5WcAAOV0AADlgwAA5YUAAOWHAADliQAA5YsAAOWNAADljwAA5ZEAAOWgAADlowAA5aYAAOWpAADlrAAA5a8AAOWyAADltQAA5bcAAOX2AADl+AAA5fsAAOX9AADmAAAA5gEAAOYCAADmAwAA5gQAAOYGAADmCAAA5gkAAOYKAADmDAAA5g0AAOYcAADmWwAA5l0AAOZfAADmYQAA5mQAAOZlAADmZgAA5mcAAOZoAADmagAA5mwAAOZtAADmbgAA5nAAAOZxAADmsAAA5rIAAOa0AADmtgAA5rkAAOa6AADmuwAA5rwAAOa9AADmvwAA5sEAAObCAADmwwAA5sUAAObGAADnBQAA5wcAAOcKAADnDAAA5w8AAOcQAADnEQAA5xIAAOcTAADnFQAA5xcAAOcYAADnGQAA5xsAAOccAADnHwAA514AAOdgAADnYgAA52QAAOdnAADnaAAA52kAAOdqAADnawAA520AAOdvAADncAAA53EAAOdzAADndAAA57MAAOe1AADntwAA57kAAOe8AADnvQAA574AAOe/AADnwAAA58IAAOfEAADnxQAA58YAAOfIAADnyQAA6AgAAOgKAADoDAAA6A4AAOgRAADoEgAA6BMAAOgUAADoFQAA6BcAAOgZAADoGgAA6BsAAOgdAADoHgAA6GkAAOiMAADorAAA6MwAAOjOAADo0AAA6NIAAOjUAADo1wAA6NgAAOjZAADo3AAA6N0AAOjfAADo4AAA6OIAAOjlAADo5gAA6OcAAOjqAADo6wAA6PQAAOkBAADpBgAA6QgAAOkKAADpDwAA6RIAAOkVAADpFwAA6TwAAOlgAADphwAA6asAAOmuAADpsAAA6bIAAOm0AADptgAA6bgAAOm5AADpvAAA6ckAAOnaAADp3AAA6d4AAOngAADp4gAA6eQAAOnmAADp6AAA6eoAAOn7AADp/gAA6gEAAOoEAADqBwAA6goAAOoNAADqEAAA6hMAAOoVAADqVAAA6lYAAOpYAADqWgAA6l0AAOpeAADqXwAA6mAAAOphAADqYwAA6mUAAOpmAADqZwAA6mkAAOpqAADqqQAA6qsAAOqtAADqrwAA6rIAAOqzAADqtAAA6rUAAOq2AADquAAA6roAAOq7AADqvAAA6r4AAOq/AADq/gAA6wAAAOsDAADrBQAA6wgAAOsJAADrCgAA6wsAAOsMAADrDgAA6xAAAOsRAADrEgAA6xQAAOsVAADrIgAA6yMAAOskAADrJgAA62UAAOtnAADraQAA62sAAOtuAADrbwAA63AAAOtxAADrcgAA63QAAOt2AADrdwAA63gAAOt6AADrewAA67oAAOu8AADrvgAA68AAAOvDAADrxAAA68UAAOvGAADrxwAA68kAAOvLAADrzAAA680AAOvPAADr0AAA7A8AAOwRAADsEwAA7BUAAOwYAADsGQAA7BoAAOwbAADsHAAA7B4AAOwgAADsIQAA7CIAAOwkAADsJQAA7GQAAOxmAADsaAAA7GoAAOxtAADsbgAA7G8AAOxwAADscQAA7HMAAOx1AADsdgAA7HcAAOx5AADsegAA7LkAAOy7AADsvQAA7L8AAOzCAADswwAA7MQAAOzFAADsxgAA7MgAAOzKAADsywAA7MwAAOzOAADszwAA7PQAAO0YAADtPwAA7WMAAO1mAADtaAAA7WoAAO1sAADtbgAA7XAAAO1xAADtdAAA7YEAAO2QAADtkgAA7ZQAAO2WAADtmAAA7ZoAAO2cAADtngAA7a0AAO2wAADtswAA7bYAAO25AADtvAAA7b8AAO3CAADtxAAA7gMAAO4FAADuBwAA7gkAAO4MAADuDQAA7g4AAO4PAADuEAAA7hIAAO4UAADuFQAA7hYAAO4YAADuGQAA7lgAAO5aAADuXAAA7l4AAO5hAADuYgAA7mMAAO5kAADuZQAA7mcAAO5pAADuagAA7msAAO5tAADubgAA7q0AAO6vAADusQAA7rMAAO62AADutwAA7rgAAO65AADuugAA7rwAAO6+AADuvwAA7sAAAO7CAADuwwAA7wIAAO8EAADvBwAA7wkAAO8MAADvDQAA7w4AAO8PAADvEAAA7xIAAO8UAADvFQAA7xYAAO8YAADvGQAA71gAAO9aAADvXAAA714AAO9hAADvYgAA72MAAO9kAADvZQAA72cAAO9pAADvagAA72sAAO9tAADvbgAA760AAO+vAADvsQAA77MAAO+2AADvtwAA77gAAO+5AADvugAA77wAAO++AADvvwAA78AAAO/CAADvwwAA8AIAAPAEAADwBgAA8AgAAPALAADwDAAA8A0AAPAOAADwDwAA8BEAAPATAADwFAAA8BUAAPAXAADwGAAA8GMAAPCGAADwpgAA8MYAAPDIAADwygAA8MwAAPDOAADw0QAA8NIAAPDTAADw1gAA8NcAAPDZAADw2gAA8NwAAPDeAADw3wAA8OAAAPDjAADw5AAA8O0AAPD6AADw/wAA8QEAAPEDAADxCAAA8QsAAPEOAADxEAAA8TUAAPFZAADxgAAA8aQAAPGnAADxqQAA8asAAPGtAADxrwAA8bEAAPGyAADxtQAA8cIAAPHTAADx1QAA8dcAAPHZAADx2wAA8d0AAPHfAADx4QAA8eMAAPH0AADx9wAA8foAAPH9AADyAAAA8gMAAPIGAADyCQAA8gwAAPIOAADyTQAA8k8AAPJRAADyUwAA8lYAAPJXAADyWAAA8lkAAPJaAADyXAAA8l4AAPJfAADyYAAA8mIAAPJjAADyogAA8qQAAPKmAADyqAAA8qsAAPKsAADyrQAA8q4AAPKvAADysQAA8rMAAPK0AADytQAA8rcAAPK4AADy9wAA8vkAAPL8AADy/gAA8wEAAPMCAADzAwAA8wQAAPMFAADzBwAA8wkAAPMKAADzCwAA8w0AAPMOAADzGwAA8xwAAPMdAADzHwAA814AAPNgAADzYgAA82QAAPNnAADzaAAA82kAAPNqAADzawAA820AAPNvAADzcAAA83EAAPNzAADzdAAA87MAAPO1AADztwAA87kAAPO8AADzvQAA874AAPO/AADzwAAA88IAAPPEAADzxQAA88YAAPPIAADzyQAA9AgAAPQKAAD0DAAA9A4AAPQRAAD0EgAA9BMAAPQUAAD0FQAA9BcAAPQZAAD0GgA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BESkAARErAAERLgABES8AAREwAAERMQABETIAARE0AAERNgABETcAARE4AAEROgABETsAARF6AAERfAABEX4AARGAAAERgwABEYQAARGFAAERhgABEYcAARGJAAERiwABEYwAARGNAAERjwABEZAAARHPAAER0QABEdQAARHWAAER2QABEdoAARHbAAER3AABEd0AARHfAAER4QABEeIAARHjAAER5QABEeYAARHzAAER9AABEfUAARH3AAESNgABEjgAARI6AAESPAABEj8AARJAAAESQQABEkIAARJDAAESRQABEkcAARJIAAESSQABEksAARJMAAESiwABEo0AARKPAAESkQABEpQAARKVAAESlgABEpcAARKYAAESmgABEpwAARKdAAESngABEqAAARKhAAES4AABEuIAARLkAAES5gABEukAARLqAAES6wABEuwAARLtAAES7wABEvEAARLyAAES8wABEvUAARL2AAETNQABEzcAARM5AAETOwABEz4AARM/AAETQAABE0EAARNCAAETRAABE0YAARNHAAETSAABE0oAARNLAAETigABE4wAAROOAAETkAABE5MAAROUAAETlQABE5YAAROXAAETmQABE5sAAROcAAETnQABE58AAROgAAETxQABE+kAARQQAAEUNAABFDcAARQ5AAEUOwABFD0AARQ/AAEUQQABFEIAARRFAAEUUgABFGEAARRjAAEUZQABFGcAARRpAAEUawABFG0AARRvAAEUfgABFIEAARSEAAEUhwABFIoAARSNAAEUkAABFJMAARSVAAEU1AABFNYAARTZAAEU2wABFN4AARTfAAEU4AABFOEAARTiAAEU5AABFOYAARTnAAEU6AABFOoAARTrAAEVKgABFSwAARUuAAEVMAABFTMAARU0AAEVNQABFTYAARU3AAEVOQABFTsAARU8AAEVPQABFT8AARVAAAEVfwABFYEAARWDAAEVhQABFYgAARWJAAEVigABFYsAARWMAAEVjgABFZAAARWRAAEVkgABFZQAARWVAAEV1AABFdYAARXZAAEV2wABFd4AARXfAAEV4AABFeEAARXiAAEV5AABFeYAARXnAAEV6AABFeoAARXrAAEWKgABFiwAARYuAAEWMAABFjMAARY0AAEWNQABFjYAARY3AAEWOQABFjsAARY8AAEWPQABFj8AARZAAAEWfwABFoEAARaDAAEWhQABFogAARaJAAEWigABFosAARaMAAEWjgABFpAAARaRAAEWkgABFpQAARaVAAEW1AABFtYAARbYAAEW2gABFt0AARbeAAEW3wABFuAAARbhAAEW4wABFuUAARbmAAEW5wABFukAARbqAAEXNQABF1gAARd4AAEXmAABF5oAARecAAEXngABF6AAARejAAEXpAABF6UAAReoAAEXqQABF6sAAResAAEXrgABF7EAAReyAAEXswABF7YAARe3AAEXwAABF80AARfSAAEX1AABF9YAARfbAAEX3gABF+EAARfjAAEYCAABGCwAARhTAAEYdwABGHoAARh8AAEYfgABGIAAARiCAAEYhAABGIUAARiIAAEYlQABGKYAARioAAEYqgABGKwAARiuAAEYsAABGLIAARi0AAEYtgABGMcAARjKAAEYzQABGNAAARjTAAEY1gABGNkAARjcAAEY3wABGOEAARkgAAEZIgABGSQAARkmAAEZKQABGSoAARkrAAEZLAABGS0AARkvAAEZMQABGTIAARkzAAEZNQABGTYAARl1AAEZdwABGXkAARl7AAEZfgABGX8AARmAAAEZgQABGYIAARmEAAEZhgABGYcAARmIAAEZigABGYsAARnKAAEZzAABGc8AARnRAAEZ1AABGdUAARnWAAEZ1wABGdgAARnaAAEZ3AABGd0AARneAAEZ4AABGeEAARnuAAEZ7wABGfAAARnyAAEaMQABGjMAARo1AAEaNwABGjoAARo7AAEaPAABGj0AARo+AAEaQAABGkIAARpDAAEaRAABGkYAARpHAAEahgABGogAARqKAAEajAABGo8AARqQAAEakQABGpIAARqTAAEalQABGpcAARqYAAEamQABGpsAARqcAAEa2wABGt0AARrfAAEa4QABGuQAARrlAAEa5gABGucAARroAAEa6gABGuwAARrtAAEa7gABGvAAARrxAAEbMAABGzIAARs0AAEbNgABGzkAARs6AAEbOwABGzwAARs9AAEbPwABG0EAARtCAAEbQwABG0UAARtGAAEbhQABG4cAARuJAAEbiwABG44AARuPAAEbkAABG5EAARuSAAEblAABG5YAARuXAAEbmAABG5oAARubAAEbwAABG+QAARwLAAEcLwABHDIAARw0AAEcNgABHDgAARw6AAEcPAABHD0AARxAAAEcTQABHFwAARxeAAEcYAABHGIAARxkAAEcZgABHGgAARxqAAEceQABHHwAARx/AAEcggABHIUAARyIAAEciwABHI4AARyQAAEczwABHNEAARzTAAEc1QABHNgAARzZAAEc2gABHNsAARzcAAEc3gABHOAAARzhAAEc4gABHOQAARzlAAEdJAABHSYAAR0oAAEdKgABHS0AAR0uAAEdLwABHTAAAR0xAAEdMwABHTUAAR02AAEdNwABHTkAAR06AAEdeQABHXsAAR19AAEdfwABHYIAAR2DAAEdhAABHYUAAR2GAAEdiAABHYoAAR2LAAEdjAABHY4AAR2PAAEdzgABHdAAAR3TAAEd1QABHdgAAR3ZAAEd2gABHdsAAR3cAAEd3gABHeAAAR3hAAEd4gABHeQAAR3lAAEeJAABHiYAAR4oAAEeKgABHi0AAR4uAAEeLwABHjAAAR4xAAEeMwABHjUAAR42AAEeNwABHjkAAR46AAEeeQABHnsAAR59AAEefwABHoIAAR6DAAEehAABHoUAAR6GAAEeiAABHooAAR6LAAEejAABHo4AAR6PAAEezgABHtAAAR7SAAEe1AABHtcAAR7YAAEe2QABHtoAAR7bAAEe3QABHt8AAR7gAAEe4QABHuMAAR7kAAEfLwABH1IAAR9yAAEfkgABH5QAAR+WAAEfmAABH5oAAR+dAAEfngABH58AAR+iAAEfowABH6UAAR+mAAEfqAABH6sAAR+sAAEfrQABH7AAAR+xAAEftgABH8MAAR/IAAEfygABH8wAAR/RAAEf1AABH9cAAR/ZAAEf/gABICIAASBJAAEgbQABIHAAASByAAEgdAABIHYAASB4AAEgegABIHsAASB+AAEgiwABIJwAASCeAAEgoAABIKIAASCkAAEgpgABIKgAASCqAAEgrAABIL0AASDAAAEgwwABIMYAASDJAAEgzAABIM8AASDSAAEg1QABINcAASEWAAEhGAABIRoAASEcAAEhHwABISAAASEhAAEhIgABISMAASElAAEhJwABISgAASEpAAEhKwABISwAASFrAAEhbQABIW8AASFxAAEhdAABIXUAASF2AAEhdwABIXgAASF6AAEhfAABIX0AASF+AAEhgAABIYEAASHAAAEhwgABIcUAASHHAAEhygABIcsAASHMAAEhzQABIc4AASHQAAEh0gABIdMAASHUAAEh1gABIdcAASHkAAEh5QABIeYAASHoAAEiJwABIikAASIrAAEiLQABIjAAASIxAAEiMgABIjMAASI0AAEiNgABIjgAASI5AAEiOgABIjwAASI9AAEifAABIn4AASKAAAEiggABIoUAASKGAAEihwABIogAASKJAAEiiwABIo0AASKOAAEijwABIpEAASKSAAEi0QABItMAASLVAAEi1wABItoAASLbAAEi3AABIt0AASLeAAEi4AABIuIAASLjAAEi5AABIuYAASLnAAEjJgABIygAASMqAAEjLAABIy8AASMwAAEjMQABIzIAASMzAAEjNQABIzcAASM4AAEjOQABIzsAASM8AAEjewABI30AASN/AAEjgQABI4QAASOFAAEjhgABI4cAASOIAAEjigABI4wAASONAAEjjgABI5AAASORAAEjtgABI9oAASQBAAEkJQABJCgAASQqAAEkLAABJC4AASQwAAEkMgABJDMAASQ2AAEkQwABJFIAASRUAAEkVgABJFgAASRaAAEkXAABJF4AASRgAAEkbwABJHIAASR1AAEkeAABJHsAASR+AAEkgQABJIQAASSGAAEkxQABJMcAASTKAAEkzAABJM8AASTQAAEk0QABJNIAASTTAAEk1QABJNcAASTYAAEk2QABJNsAASTcAAEk6wABJSoAASUsAAElLgABJTAAASUzAAElNAABJTUAASU2AAElNwABJTkAASU7AAElPAABJT0AASU/AAElQAABJX8AASWBAAElgwABJYUAASWIAAEliQABJYoAASWLAAEljAABJY4AASWQAAElkQABJZIAASWUAAEllQABJdQAASXWAAEl2QABJdsAASXeAAEl3wABJeAAASXhAAEl4gABJeQAASXmAAEl5wABJegAASXqAAEl6wABJioAASYsAAEmLgABJjAAASYzAAEmNAABJjUAASY2AAEmNwABJjkAASY7AAEmPAABJj0AASY/AAEmQAABJn8AASaBAAEmgwABJoUAASaIAAEmiQABJooAASaLAAEmjAABJo4AASaQAAEmkQABJpIAASaUAAEmlQABJtQAASbWAAEm2AABJtoAASbdAAEm3gABJt8AASbgAAEm4QABJuMAASblAAEm5gABJucAASbpAAEm6gABJzUAASdYAAEneAABJ5gAASeaAAEnnAABJ54AASegAAEnowABJ6QAASelAAEnqAABJ6kAASerAAEnrAABJ64AASexAAEnsgABJ7MAASe2AAEntwABJ7wAASfJAAEnzgABJ9AAASfSAAEn1wABJ9oAASfdAAEn3wABKAQAASgoAAEoTwABKHMAASh2AAEoeAABKHoAASh8AAEofgABKIAAASiBAAEohAABKJEAASiiAAEopAABKKYAASioAAEoqgABKKwAASiuAAEosAABKLIAASjDAAEoxgABKMkAASjMAAEozwABKNIAASjVAAEo2AABKNsAASjdAAEpHAABKR4AASkgAAEpIgABKSUAASkmAAEpJwABKSgAASkpAAEpKwABKS0AASkuAAEpLwABKTEAASkyAAEpcQABKXMAASl1AAEpdwABKXoAASl7AAEpfAABKX0AASl+AAEpgAABKYIAASmDAAEphAABKYYAASmHAAEpxgABKcgAASnLAAEpzQABKdAAASnRAAEp0gABKdMAASnUAAEp1gABKdgAASnZAAEp2gABKdwAASndAAEp6gABKesAASnsAAEp7gABKi0AASovAAEqMQABKjMAASo2AAEqNwABKjgAASo5AAEqOgABKjwAASo+AAEqPwABKkAAASpCAAEqQwABKoIAASqEAAEqhgABKogAASqLAAEqjAABKo0AASqOAAEqjwABKpEAASqTAAEqlAABKpUAASqXAAEqmAABKtcAASrZAAEq2wABKt0AASrgAAEq4QABKuIAASrjAAEq5AABKuYAASroAAEq6QABKuoAASrsAAEq7QABKywAASsuAAErMAABKzIAASs1AAErNgABKzcAASs4AAErOQABKzsAASs9AAErPgABKz8AAStBAAErQgABK4EAASuDAAErhQABK4cAASuKAAEriwABK4wAASuNAAErjgABK5AAASuSAAErkwABK5QAASuWAAErlwABK7wAASvgAAEsBwABLCsAASwuAAEsMAABLDIAASw0AAEsNgABLDgAASw5AAEsPAABLEkAASxYAAEsWgABLFwAASxeAAEsYAABLGIAASxkAAEsZgABLHUAASx4AAEsewABLH4AASyBAAEshAABLIcAASyKAAEsjAABLMsAASzNAAEszwABLNEAASzUAAEs1QABLNYAASzXAAEs2AABLNoAASzcAAEs3QABLN4AASzgAAEs4QABLSAAAS0iAAEtJAABLSYAAS0pAAEtKgABLSsAAS0sAAEtLQABLS8AAS0xAAEtMgABLTMAAS01AAEtNgABLXUAAS13AAEteQABLXsAAS1+AAEtfwABLYAAAS2BAAEtggABLYQAAS2GAAEthwABLYgAAS2KAAEtiwABLcoAAS3MAAEtzwABLdEAAS3UAAEt1QABLdYAAS3XAAEt2AABLdoAAS3cAAEt3QABLd4AAS3gAAEt4QABLiAAAS4iAAEuJAABLiYAAS4pAAEuKgABLisAAS4sAAEuLQABLi8AAS4xAAEuMgABLjMAAS41AAEuNgABLnUAAS53AAEueQABLnsAAS5+AAEufwABLoAAAS6BAAEuggABLoQAAS6GAAEuhwABLogAAS6KAAEuiwABLsoAAS7MAAEuzgABLtAAAS7TAAEu1AABLtUAAS7WAAEu1wABLtkAAS7bAAEu3AABLt0AAS7fAAEu4AABLysAAS9OAAEvbgABL44AAS+QAAEvkgABL5QAAS+WAAEvmQABL5oAAS+bAAEvngABL58AAS+hAAEvogABL6QAAS+nAAEvqAABL6kAAS+sAAEvrQABL7IAAS+/AAEvxAABL8YAAS/IAAEvzQABL9AAAS/TAAEv1QABL/oAATAeAAEwRQABMGkAATBsAAEwbgABMHAAATByAAEwdAABMHYAATB3AAEwegABMIcAATCYAAEwmgABMJwAATCeAAEwoAABMKIAATCkAAEwpgABMKgAATC5AAEwvAABML8AATDCAAEwxQABMMgAATDLAAEwzgABMNEAATDTAAExEgABMRQAATEWAAExGAABMRsAATEcAAExHQABMR4AATEfAAExIQABMSMAATEkAAExJQABMScAATEoAAExZwABMWkAATFrAAExbQABMXAAATFxAAExcgABMXMAATF0AAExdgABMXgAATF5AAExegABMXwAATF9AAExvAABMb4AATHBAAExwwABMcYAATHHAAExyAABMckAATHKAAExzAABMc4AATHPAAEx0AABMdIAATHTAAEx4AABMeEAATHiAAEx5AABMiMAATIlAAEyJwABMikAATIsAAEyLQABMi4AATIvAAEyMAABMjIAATI0AAEyNQABMjYAATI4AAEyOQABMngAATJ6AAEyfAABMn4AATKBAAEyggABMoMAATKEAAEyhQABMocAATKJAAEyigABMosAATKNAAEyjgABMs0AATLPAAEy0QABMtMAATLWAAEy1wABMtgAATLZAAEy2gABMtwAATLeAAEy3wABMuAAATLiAAEy4wABMyIAATMkAAEzJgABMygAATMrAAEzLAABMy0AATMuAAEzLwABMzEAATMzAAEzNAABMzUAATM3AAEzOAABM3cAATN5AAEzewABM30AATOAAAEzgQABM4IAATODAAEzhAABM4YAATOIAAEziQABM4oAATOMAAEzjQABM7IAATPWAAEz/QABNCEAATQkAAE0JgABNCgAATQqAAE0LAABNC4AATQvAAE0MgABND8AATROAAE0UAABNFIAATRUAAE0VgABNFgAATRaAAE0XAABNGsAATRuAAE0cQABNHQAATR3AAE0egABNH0AATSAAAE0ggABNMEAATTDAAE0xgABNMgAATTLAAE0zAABNM0AATTOAAE0zwABNNEAATTTAAE01AABNNUAATTXAAE02AABNRcAATUZAAE1GwABNR0AATUgAAE1IQABNSIAATUjAAE1JAABNSYAATUoAAE1KQABNSoAATUsAAE1LQABNWwAATVuAAE1cAABNXIAATV1AAE1dgABNXcAATV4AAE1eQABNXsAATV9AAE1fgABNX8AATWBAAE1ggABNcEAATXDAAE1xgABNcgAATXLAAE1zAABNc0AATXOAAE1zwABNdEAATXTAAE11AABNdUAATXXAAE12AABNhcAATYZAAE2GwABNh0AATYgAAE2IQABNiIAATYjAAE2JAABNiYAATYoAAE2KQABNioAATYsAAE2LQABNmwAATZuAAE2cAABNnIAATZ1AAE2dgABNncAATZ4AAE2eQABNnsAATZ9AAE2fgABNn8AATaBAAE2ggABNsEAATbDAAE2xQABNscAATbKAAE2ywABNswAATbNAAE2zgABNtAAATbSAAE20wABNtQAATbWAAE21wABNyIAATdFAAE3ZQABN4UAATeHAAE3iQABN4sAATeNAAE3kAABN5EAATeSAAE3lQABN5YAATeYAAE3mQABN5sAATedAAE3ngABN58AATeiAAE3owABN6gAATe1AAE3ugABN7wAATe+AAE3wwABN8YAATfJAAE3ywABN/AAATgUAAE4OwABOF8AAThiAAE4ZAABOGYAAThoAAE4agABOGwAAThtAAE4cAABOH0AATiOAAE4kAABOJIAATiUAAE4lgABOJgAATiaAAE4nAABOJ4AATivAAE4sgABOLUAATi4AAE4uwABOL4AATjBAAE4xAABOMcAATjJAAE5CAABOQoAATkMAAE5DgABOREAATkSAAE5EwABORQAATkVAAE5FwABORkAATkaAAE5GwABOR0AATkeAAE5XQABOV8AATlhAAE5YwABOWYAATlnAAE5aAABOWkAATlqAAE5bAABOW4AATlvAAE5cAABOXIAATlzAAE5sgABObQAATm3AAE5uQABObwAATm9AAE5vgABOb8AATnAAAE5wgABOcQAATnFAAE5xgABOcgAATnJAAE51gABOdcAATnYAAE52gABOhkAATobAAE6HQABOh8AAToiAAE6IwABOiQAATolAAE6JgABOigAAToqAAE6KwABOiwAATouAAE6LwABOm4AATpwAAE6cgABOnQAATp3AAE6eAABOnkAATp6AAE6ewABOn0AATp/AAE6gAABOoEAATqDAAE6hAABOsMAATrFAAE6xwABOskAATrMAAE6zQABOs4AATrPAAE60AABOtIAATrUAAE61QABOtYAATrYAAE62QABOxgAATsaAAE7HAABOx4AATshAAE7IgABOyMAATskAAE7JQABOycAATspAAE7KgABOysAATstAAE7LgABO20AATtvAAE7cQABO3MAATt2AAE7dwABO3gAATt5AAE7egABO3wAATt+AAE7fwABO4AAATuCAAE7gwABO6gAATvMAAE78wABPBcAATwaAAE8HAABPB4AATwgAAE8IgABPCQAATwlAAE8KAABPDUAATxEAAE8RgABPEgAATxKAAE8TAABPE4AATxQAAE8UgABPGEAATxkAAE8ZwABPGoAATxtAAE8cAABPHMAATx2AAE8eAABPLcAATy5AAE8vAABPL4AATzBAAE8wgABPMMAATzEAAE8xQABPMcAATzJAAE8ygABPMsAATzNAAE8zgABPQ0AAT0PAAE9EQABPRMAAT0WAAE9FwABPRgAAT0ZAAE9GgABPRwAAT0eAAE9HwABPSAAAT0iAAE9IwABPWIAAT1kAAE9ZgABPWgAAT1rAAE9bAABPW0AAT1uAAE9bwABPXEAAT1zAAE9dAA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BVAgAAVQJAAFUDAABVBkAAVQoAAFUKgABVCwAAVQuAAFUMAABVDIAAVQ0AAFUNgABVEUAAVRIAAFUSwABVE4AAVRRAAFUVAABVFcAAVRaAAFUXAABVJsAAVSdAAFUnwABVKEAAVSkAAFUpQABVKYAAVSnAAFUqAABVKoAAVSsAAFUrQABVK4AAVSwAAFUsQABVPAAAVTyAAFU9AABVPYAAVT5AAFU+gABVPsAAVT8AAFU/QABVP8AAVUBAAFVAgABVQMAAVUFAAFVBgABVUUAAVVHAAFVSQABVUsAAVVOAAFVTwABVVAAAVVRAAFVUgABVVQAAVVWAAFVVwABVVgAAVVaAAFVWwABVZoAAVWcAAFVngABVaAAAVWjAAFVpAABVaUAAVWmAAFVpwABVakAAVWrAAFVrAABVa0AAVWvAAFVsAABVe8AAVXxAAFV8wABVfUAAVX4AAFV+QABVfoAAVX7AAFV/AABVf4AAVYAAAFWAQABVgIAAVYEAAFWBQABVkQAAVZGAAFWSAABVkoAAVZNAAFWTgABVk8AAVZQAAFWUQABVlMAAVZVAAFWVgABVlcAAVZZAAFWWgABVpkAAVabAAFWnQABVp8AAVaiAAFWowABVqQAAValAAFWpgABVqgAAVaqAAFWqwABVqwAAVauAAFWrwABVvoAAVcdAAFXPQABV10AAVdfAAFXYQABV2MAAVdlAAFXaAABV2kAAVdqAAFXbQABV24AAVdwAAFXcQABV3MAAVd2AAFXdwABV3gAAVd7AAFXfAABV4EAAVeOAAFXkwABV5UAAVeXAAFXnAABV58AAVeiAAFXpAABV8kAAVftAAFYFAABWDgAAVg7AAFYPQABWD8AAVhBAAFYQwABWEUAAVhGAAFYSQABWFYAAVhnAAFYaQABWGsAAVhtAAFYbwABWHEAAVhzAAFYdQABWHcAAViIAAFYiwABWI4AAViRAAFYlAABWJcAAViaAAFYnQABWKAAAViiAAFY4QABWOMAAVjlAAFY5wABWOoAAVjrAAFY7AABWO0AAVjuAAFY8AABWPIAAVjzAAFY9AABWPYAAVj3AAFZNgABWTgAAVk6AAFZPAABWT8AAVlAAAFZQQABWUIAAVlDAAFZRQABWUcAAVlIAAFZSQABWUsAAVlMAAFZiwABWY0AAVmQAAFZkgABWZUAAVmWAAFZlwABWZgAAVmZAAFZmwABWZ0AAVmeAAFZnwABWaEAAVmiAAFZrwABWbAAAVmxAAFZswABWfIAAVn0AAFZ9gABWfgAAVn7AAFZ/AABWf0AAVn+AAFZ/wABWgEAAVoDAAFaBAABWgUAAVoHAAFaCAABWkcAAVpJAAFaSwABWk0AAVpQAAFaUQABWlIAAVpTAAFaVAABWlYAAVpYAAFaWQABWloAAVpcAAFaXQABWpwAAVqeAAFaoAABWqIAAVqlAAFapgABWqcAAVqoAAFaqQABWqsAAVqtAAFargABWq8AAVqxAAFasgABWvEAAVrzAAFa9QABWvcAAVr6AAFa+wABWvwAAVr9AAFa/gABWwAAAVsCAAFbAwABWwQAAVsGAAFbBwABW0YAAVtIAAFbSgABW0wAAVtPAAFbUAABW1EAAVtSAAFbUwABW1UAAVtXAAFbWAABW1kAAVtbAAFbXAABW4EAAVulAAFbzAABW/AAAVvzAAFb9QABW/cAAVv5AAFb+wABW/0AAVv+AAFcAQABXA4AAVwdAAFcHwABXCEAAVwjAAFcJQABXCcAAVwpAAFcKwABXDoAAVw9AAFcQAABXEMAAVxGAAFcSQABXEwAAVxPAAFcUQABXJAAAVySAAFclQABXJcAAVyaAAFcmwABXJwAAVydAAFcngABXKAAAVyiAAFcowABXKQAAVymAAFcpwABXOYAAVzoAAFc6gABXOwAAVzvAAFc8AABXPEAAVzyAAFc8wABXPUAAVz3AAFc+AABXPkAAVz7AAFc/AABXTsAAV09AAFdPwABXUEAAV1EAAFdRQABXUYAAV1HAAFdSAABXUoAAV1MAAFdTQABXU4AAV1QAAFdUQABXZAAAV2SAAFdlQABXZcAAV2aAAFdmwABXZwAAV2dAAFdngABXaAAAV2iAAFdowABXaQAAV2mAAFdpwABXeYAAV3oAAFd6gABXewAAV3vAAFd8AABXfEAAV3yAAFd8wABXfUAAV33AAFd+AABXfkAAV37AAFd/AABXjsAAV49AAFePwABXkEAAV5EAAFeRQABXkYAAV5HAAFeSAABXkoAAV5MAAFeTQABXk4AAV5QAAFeUQABXpAAAV6SAAFelAABXpYAAV6ZAAFemgABXpsAAV6cAAFenQABXp8AAV6hAAFeogABXqMAAV6lAAFepgABXvEAAV8UAAFfNAABX1QAAV9WAAFfWAABX1oAAV9cAAFfXwABX2AAAV9hAAFfZAABX2UAAV9nAAFfaAABX2oAAV9tAAFfbgABX28AAV9yAAFfcwABX3gAAV+FAAFfigABX4wAAV+OAAFfkwABX5YAAV+ZAAFfmwABX8AAAV/kAAFgCwABYC8AAWAyAAFgNAABYDYAAWA4AAFgOgABYDwAAWA9AAFgQAABYE0AAWBeAAFgYAABYGIAAWBkAAFgZgABYGgAAWBqAAFgbAABYG4AAWB/AAFgggABYIUAAWCIAAFgiwABYI4AAWCRAAFglAABYJcAAWCZAAFg2AABYNoAAWDcAAFg3gABYOEAAWDiAAFg4wABYOQAAWDlAAFg5wABYOkAAWDqAAFg6wABYO0AAWDuAAFhLQABYS8AAWExAAFhMwABYTYAAWE3AAFhOAABYTkAAWE6AAFhPAABYT4AAWE/AAFhQAABYUIAAWFDAAFhggABYYQAAWGHAAFhiQABYYwAAWGNAAFhjgABYY8AAWGQAAFhkgABYZQAAWGVAAFhlgABYZgAAWGZAAFhpgABYacAAWGoAAFhqgABYekAAWHrAAFh7QABYe8AAWHyAAFh8wABYfQAAWH1AAFh9gABYfgAAWH6AAFh+wABYfwAAWH+AAFh/wABYj4AAWJAAAFiQgABYkQAAWJHAAFiSAABYkkAAWJKAAFiSwABYk0AAWJPAAFiUAABYlEAAWJTAAFiVAABYpMAAWKVAAFilwABYpkAAWKcAAFinQABYp4AAWKfAAFioAABYqIAAWKkAAFipQABYqYAAWKoAAFiqQABYugAAWLqAAFi7AABYu4AAWLxAAFi8gABYvMAAWL0AAFi9QABYvcAAWL5AAFi+gABYvsAAWL9AAFi/gABYz0AAWM/AAFjQQABY0MAAWNGAAFjRwABY0gAAWNJAAFjSgABY0wAAWNOAAFjTwABY1AAAWNSAAFjUwABY3gAAWOcAAFjwwABY+cAAWPqAAFj7AABY+4AAWPwAAFj8gABY/QAAWP1AAFj+AABZAUAAWQUAAFkFgABZBgAAWQaAAFkHAABZB4AAWQgAAFkIgABZDEAAWQ0AAFkNwABZDoAAWQ9AAFkQAABZEMAAWRGAAFkSAABZIcAAWSJAAFkjAABZI4AAWSRAAFkkgABZJMAAWSUAAFklQABZJcAAWSZAAFkmgABZJsAAWSdAAFkngABZN0AAWTfAAFk4QABZOMAAWTmAAFk5wABZOgAAWTpAAFk6gABZOwAAWTuAAFk7wABZPAAAWTyAAFk8wABZTIAAWU0AAFlNgABZTgAAWU7AAFlPAABZT0AAWU+AAFlPwABZUEAAWVDAAFlRAABZUUAAWVHAAFlSAABZYcAAWWJAAFljAABZY4AAWWRAAFlkgABZZMAAWWUAAFllQABZZcAAWWZAAFlmgABZZsAAWWdAAFlngABZd0AAWXfAAFl4QABZeMAAWXmAAFl5wABZegAAWXpAAFl6gABZewAAWXuAAFl7wABZfAAAWXyAAFl8wABZjIAAWY0AAFmNgABZjgAAWY7AAFmPAABZj0AAWY+AAFmPwABZkEAAWZDAAFmRAABZkUAAWZHAAFmSAABZocAAWaJAAFmiwABZo0AAWaQAAFmkQABZpIAAWaTAAFmlAABZpYAAWaYAAFmmQABZpoAAWacAAFmnQABZqYAAWanAAFmqQABZuwAAWcQAAFnNAABZ1cAAWd+AAFnngABZ8UAAWfsAAFoDAABaDAAAWhUAAFoVgABaFkAAWhbAAFoXQABaF8AAWhiAAFoZQABaGcAAWhpAAFobAABaG4AAWhwAAFocwABaHYAAWh3AAFogAABaI0AAWiQAAFokgABaJUAAWiYAAFomgABaL8AAWjjAAFpCgABaS4AAWkxAAFpMwABaTUAAWk3AAFpOQABaTsAAWk8AAFpPwABaUwAAWlfAAFpYQABaWMAAWllAAFpZwABaWkAAWlrAAFpbQABaW8AAWlxAAFphAABaYcAAWmKAAFpjQABaZAAAWmTAAFplgABaZkAAWmcAAFpnwABaaEAAWngAAFp4gABaeUAAWnnAAFp6gABaesAAWnsAAFp7QABae4AAWnwAAFp8gABafMAAWn0AAFp9gABafcAAWoAAAFqAQABagMAAWpCAAFqRAABakYAAWpIAAFqSwABakwAAWpNAAFqTgABak8AAWpRAAFqUwABalQAAWpVAAFqVwABalgAAWqXAAFqmQABapwAAWqeAAFqoQABaqIAAWqjAAFqpAABaqUAAWqnAAFqqQABaqoAAWqrAAFqrQABaq4AAWq3AAFquAABaroAAWr5AAFq+wABav0AAWr/AAFrAgABawMAAWsEAAFrBQABawYAAWsIAAFrCgABawsAAWsMAAFrDgABaw8AAWtOAAFrUAABa1MAAWtVAAFrWAABa1kAAWtaAAFrWwABa1wAAWteAAFrYAABa2EAAWtiAAFrZAABa2UAAWtuAAFrbwABa3EAAWuwAAFrsgABa7QAAWu2AAFruQABa7oAAWu7AAFrvAABa70AAWu/AAFrwQABa8IAAWvDAAFrxQABa8YAAWwFAAFsBwABbAoAAWwMAAFsDwABbBAAAWwRAAFsEgABbBMAAWwVAAFsFwABbBgAAWwZAAFsGwABbBwAAWwpAAFsKgABbCsAAWwtAAFsbAABbG4AAWxwAAFscgABbHUAAWx2AAFsdwABbHgAAWx5AAFsewABbH0AAWx+AAFsfwABbIEAAWyCAAFswQABbMMAAWzGAAFsyAABbMsAAWzMAAFszQABbM4AAWzPAAFs0QABbNMAAWzUAAFs1QABbNcAAWzYAAFs4gABbO8AAWz+AAFtAQABbQQAAW0HAAFtCgABbQ0AAW0QAAFtEwABbSIAAW0lAAFtKAABbSsAAW0uAAFtMQABbTQAAW03AAFtOQABbUwAAW1eAAFtawABbXgAAW2GAAFtjgABbZMAAW3eAAFuAQABbiEAAW5BAAFuQwABbkUAAW5HAAFuSQABbkwAAW5NAAFuTgABblEAAW5SAAFuVAABblUAAW5XAAFuWgABblsAAW5cAAFuXwABbmAAAW5lAAFucgABbncAAW55AAFuewABboAAAW6DAAFuhgABbogAAW6tAAFu0QABbvgAAW8cAAFvHwABbyEAAW8jAAFvJQABbycAAW8pAAFvKgABby0AAW86AAFvSwABb00AAW9PAAFvUQABb1MAAW9VAAFvVwABb1kAAW9bAAFvbAABb28AAW9yAAFvdQABb3gAAW97AAFvfgABb4EAAW+EAAFvhgABb8UAAW/HAAFvyQABb8sAAW/OAAFvzwABb9AAAW/RAAFv0gABb9QAAW/WAAFv1wABb9gAAW/aAAFv2wABcBoAAXAcAAFwHgABcCAAAXAjAAFwJAABcCUAAXAmAAFwJwABcCkAAXArAAFwLAABcC0AAXAvAAFwMAABcG8AAXBxAAFwdAABcHYAAXB5AAFwegABcHsAAXB8AAFwfQABcH8AAXCBAAFwggABcIMAAXCFAAFwhgABcJMAAXCUAAFwlQABcJcAAXDWAAFw2AABcNoAAXDcAAFw3wABcOAAAXDhAAFw4gABcOMAAXDlAAFw5wABcOgAAXDpAAFw6wABcOwAAXErAAFxLQABcS8AAXExAAFxNAABcTUAAXE2AAFxNwABcTgAAXE6AAFxPAABcT0AAXE+AAFxQAABcUEAAXGAAAFxggABcYQAAXGGAAFxiQABcYoAAXGLAAFxjAABcY0AAXGPAAFxkQABcZIAAXGTAAFxlQABcZYAAXHVAAFx1wABcdkAAXHbAAFx3gABcd8AAXHgAAFx4QABceIAAXHkAAFx5gABcecAAXHoAAFx6gABcesAAXIqAAFyLAABci4AAXIwAAFyMwABcjQAAXI1AAFyNgABcjcAAXI5AAFyOwABcjwAAXI9AAFyPwABckAAAXJlAAFyiQABcrAAAXLUAAFy1wABctkAAXLbAAFy3QABct8AAXLhAAFy4gABcuUAAXLyAAFzAQABcwMAAXMFAAFzBwABcwkAAXMLAAFzDQABcw8AAXMeAAFzIQABcyQAAXMnAAFzKgABcy0AAXMwAAFzMwABczUAAXN0AAFzdgABc3gAAXN6AAFzfQABc34AAXN/AAFzgAABc4EAAXODAAFzhQABc4YAAXOHAAFziQABc4oAAXPJAAFzywABc80AAXPPAAFz0gABc9MAAXPUAAFz1QABc9YAAXPYAAFz2gABc9sAAXPcAAFz3gABc98AAXQeAAF0IAABdCIAAXQkAAF0JwABdCgAAXQpAAF0KgABdCsAAXQtAAF0LwABdDAAAXQxAAF0MwABdDQAAXRzAAF0dQABdHcAAXR5AAF0fAABdH0AAXR+AAF0fwABdIAAAXSCAAF0hAABdIUAAXSGAAF0iAABdIkAAXTIAAF0ygABdMwAAXTOAAF00QABdNIAAXTTAAF01AABdNUAAXTXAAF02QABdNoAAXTbAAF03QABdN4AAXUdAAF1HwABdSEAAXUjAAF1JgABdScAAXUoAAF1KQABdSoAAXUsAAF1LgABdS8AAXUwAAF1MgABdTMAAXVyAAF1dAABdXYAAXV4AAF1ewABdXwAAXV9AAF1fgABdX8AAXWBAAF1gwABdYQAAXWFAAF1hwABdYgAAXXTAAF19gABdhYAAXY2AAF2OAABdjoAAXY8AAF2PgABdkEAAXZCAAF2QwABdkYAAXZHAAF2SQABdkoAAXZMAAF2TwABdlAAAXZRAAF2VAABdlUAAXZaAAF2ZwABdmwAAXZuAAF2cAABdnUAAXZ4AAF2ewABdn0AAXaiAAF2xgABdu0AAXcRAAF3FAABdxYAAXcYAAF3GgABdxwAAXceAAF3HwABdyIAAXcvAAF3QAABd0IAAXdEAAF3RgABd0gAAXdKAAF3TAABd04AAXdQAAF3YQABd2QAAXdnAAF3agABd20AAXdwAAF3cwABd3YAAXd5AAF3ewABd7oAAXe8AAF3vgABd8AAAXfDAAF3xAABd8UAAXfGAAF3xwABd8kAAXfLAAF3zAABd80AAXfPAAF30AABeA8AAXgRAAF4EwABeBUAAXgYAAF4GQABeBoAAXgbAAF4HAABeB4AAXggAAF4IQABeCIAAXgkAAF4JQABeGQAAXhmAAF4aQABeGsAAXhuAAF4bwABeHAAAXhxAAF4cgABeHQAAXh2AAF4dwABeHgAAXh6AAF4ewABeIgAAXiJAAF4igABeIwAAXjLAAF4zQABeM8AAXjRAAF41AABeNUAAXjWAAF41wABeNgAAXjaAAF43AABeN0AAXjeAAF44AABeOEAAXkgAAF5IgABeSQAAXkmAAF5KQABeSoAAXkrAAF5LAABeS0AAXkvAAF5MQABeTIAAXkzAAF5NQABeTYAAXl1AAF5dwABeXkAAXl7AAF5fgABeX8AAXmAAAF5gQABeYIAAXmEAAF5hgABeYcAAXmIAAF5igABeYsAAXnKAAF5zAABec4AAXnQAAF50wABedQAAXnVAAF51gABedcAAXnZAAF52wABedwAAXndAAF53wABeeAAAXofAAF6IQABeiMAAXolAAF6KAABeikAAXoqAAF6KwABeiwAAXouAAF6MAABejEAAXoyAAF6NAABejUAAXpaAAF6fgABeqUAAXrJAAF6zAABes4AAXrQAAF60gABetQAAXrWAAF61wABetoAAXrnAAF69gABevgAAXr6AAF6/AABev4AAXsAAAF7AgABewQAAXsTAAF7FgABexkAAXscAAF7HwABeyIAAXslAAF7KAABeyoAAXtpAAF7awABe20AAXtvAAF7cgABe3MAAXt0AAF7dQABe3YAAXt4AAF7egABe3sAAXt8AAF7fgABe38AAXu+AAF7wAABe8IAAXvEAAF7xwABe8gAAXvJAAF7ygABe8sAAXvNAAF7zwA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Bl/IAAZfzAAGX9AABl/YAAZf4AAGX+QABl/oAAZf8AAGX/QABmDwAAZg+AAGYQQABmEMAAZhGAAGYRwABmEgAAZhJAAGYSgABmEwAAZhOAAGYTwABmFAAAZhSAAGYUwABmGAAAZhhAAGYYgABmGQAAZijAAGYpQABmKcAAZipAAGYrAABmK0AAZiuAAGYrwABmLAAAZiyAAGYtAABmLUAAZi2AAGYuAABmLkAAZj4AAGY+gABmPwAAZj+AAGZAQABmQIAAZkDAAGZBAABmQUAAZkHAAGZCQABmQoAAZkLAAGZDQABmQ4AAZlNAAGZTwABmVEAAZlTAAGZVgABmVcAAZlYAAGZWQABmVoAAZlcAAGZXgABmV8AAZlgAAGZYgABmWMAAZmiAAGZpAABmaYAAZmoAAGZqwABmawAAZmtAAGZrgABma8AAZmxAAGZswABmbQAAZm1AAGZtwABmbgAAZn3AAGZ+QABmfsAAZn9AAGaAAABmgEAAZoCAAGaAwABmgQAAZoGAAGaCAABmgkAAZoKAAGaDAABmg0AAZoyAAGaVgABmn0AAZqhAAGapAABmqYAAZqoAAGaqgABmqwAAZquAAGarwABmrIAAZq/AAGazgABmtAAAZrSAAGa1AABmtYAAZrYAAGa2gABmtwAAZrrAAGa7gABmvEAAZr0AAGa9wABmvoAAZr9AAGbAAABmwIAAZtBAAGbQwABm0UAAZtHAAGbSgABm0sAAZtMAAGbTQABm04AAZtQAAGbUgABm1MAAZtUAAGbVgABm1cAAZuWAAGbmAABm5oAAZucAAGbnwABm6AAAZuhAAGbogABm6MAAZulAAGbpwABm6gAAZupAAGbqwABm6wAAZvrAAGb7QABm+8AAZvxAAGb9AABm/UAAZv2AAGb9wABm/gAAZv6AAGb/AABm/0AAZv+AAGcAAABnAEAAZxAAAGcQgABnEQAAZxGAAGcSQABnEoAAZxLAAGcTAABnE0AAZxPAAGcUQABnFIAAZxTAAGcVQABnFYAAZyVAAGclwABnJkAAZybAAGcngABnJ8AAZygAAGcoQABnKIAAZykAAGcpgABnKcAAZyoAAGcqgABnKsAAZzqAAGc7AABnO4AAZzwAAGc8wABnPQAAZz1AAGc9gABnPcAAZz5AAGc+wABnPwAAZz9AAGc/wABnQAAAZ0/AAGdQQABnUMAAZ1FAAGdSAABnUkAAZ1KAAGdSwABnUwAAZ1OAAGdUAABnVEAAZ1SAAGdVAABnVUAAZ2gAAGdwwABneMAAZ4DAAGeBQABngcAAZ4JAAGeCwABng4AAZ4PAAGeEAABnhMAAZ4UAAGeFgABnhcAAZ4ZAAGeHAABnh0AAZ4eAAGeIQABniIAAZ4nAAGeNAABnjkAAZ47AAGePQABnkIAAZ5FAAGeSAABnkoAAZ5vAAGekwABnroAAZ7eAAGe4QABnuMAAZ7lAAGe5wABnukAAZ7rAAGe7AABnu8AAZ78AAGfDQABnw8AAZ8RAAGfEwABnxUAAZ8XAAGfGQABnxsAAZ8dAAGfLgABnzEAAZ80AAGfNwABnzoAAZ89AAGfQAABn0MAAZ9GAAGfSAABn4cAAZ+JAAGfiwABn40AAZ+QAAGfkQABn5IAAZ+TAAGflAABn5YAAZ+YAAGfmQABn5oAAZ+cAAGfnQABn9wAAZ/eAAGf4AABn+IAAZ/lAAGf5gABn+cAAZ/oAAGf6QABn+sAAZ/tAAGf7gABn+8AAZ/xAAGf8gABoDEAAaAzAAGgNgABoDgAAaA7AAGgPAABoD0AAaA+AAGgPwABoEEAAaBDAAGgRAABoEUAAaBHAAGgSAABoFUAAaBWAAGgVwABoFkAAaCYAAGgmgABoJwAAaCeAAGgoQABoKIAAaCjAAGgpAABoKUAAaCnAAGgqQABoKoAAaCrAAGgrQABoK4AAaDtAAGg7wABoPEAAaDzAAGg9gABoPcAAaD4AAGg+QABoPoAAaD8AAGg/gABoP8AAaEAAAGhAgABoQMAAaFCAAGhRAABoUYAAaFIAAGhSwABoUwAAaFNAAGhTgABoU8AAaFRAAGhUwABoVQAAaFVAAGhVwABoVgAAaGXAAGhmQABoZsAAaGdAAGhoAABoaEAAaGiAAGhowABoaQAAaGmAAGhqAABoakAAaGqAAGhrAABoa0AAaHsAAGh7gABofAAAaHyAAGh9QABofYAAaH3AAGh+AABofkAAaH7AAGh/QABof4AAaH/AAGiAQABogIAAaInAAGiSwABonIAAaKWAAGimQABopsAAaKdAAGinwABoqEAAaKjAAGipAABoqcAAaK0AAGiwwABosUAAaLHAAGiyQABossAAaLNAAGizwABotEAAaLgAAGi4wABouYAAaLpAAGi7AABou8AAaLyAAGi9QABovcAAaM2AAGjOAABozoAAaM8AAGjPwABo0AAAaNBAAGjQgABo0MAAaNFAAGjRwABo0gAAaNJAAGjSwABo0wAAaOLAAGjjQABo48AAaORAAGjlAABo5UAAaOWAAGjlwABo5gAAaOaAAGjnAABo50AAaOeAAGjoAABo6EAAaPgAAGj4gABo+QAAaPmAAGj6QABo+oAAaPrAAGj7AABo+0AAaPvAAGj8QABo/IAAaPzAAGj9QABo/YAAaQ1AAGkNwABpDkAAaQ7AAGkPgABpD8AAaRAAAGkQQABpEIAAaREAAGkRgABpEcAAaRIAAGkSgABpEsAAaSKAAGkjAABpI4AAaSQAAGkkwABpJQAAaSVAAGklgABpJcAAaSZAAGkmwABpJwAAaSdAAGknwABpKAAAaTfAAGk4QABpOMAAaTlAAGk6AABpOkAAaTqAAGk6wABpOwAAaTuAAGk8AABpPEAAaTyAAGk9AABpPUAAaU0AAGlNgABpTgAAaU6AAGlPQABpT4AAaU/AAGlQAABpUEAAaVDAAGlRQABpUYAAaVHAAGlSQABpUoAAaVTAAGlVAABpVYAAaVjAAGlZAABpWUAAaVnAAGldAABpXUAAaV2AAGleAABpYUAAaWGAAGlhwABpYkAAaWSAAGloQABpa4AAaW9AAGlzwABpeMAAaX6AAGmDAABphUAAaYWAAGmGAABpiUAAaYmAAGmJwABpikAAaYyAAGmPAABpkMAAAAAAAAEAgAAAAAAAD/JAAAAAAAAAAAAAAAAAAGmSw== + + EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel + 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5QwtDGkMpQzhDR0NWQ2VDdEN3Q3tDf0ODQ4tDjkOSVSRudWxs1wANAA4ADwAQABEAEgATABQAFQAWABcAGAAXABpfEA9feGRfcm9vdFBhY2thZ2VWJGNsYXNzXF94ZF9jb21tZW50c18QEF94ZF9tb2RlbE1hbmFnZXJfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVdX3hkX21vZGVsTmFtZV8QF19tb2RlbFZlcnNpb25JZGVudGlmaWVygAKBBUCBBT6AAIEFP4AAgQEC3gAcAB0AHgAfACAAIQAiAA4AIwAkACUAJgAnACgAKQAqACsACQApABcALwAwADEAMgAzACkAKQAXXxAcWERCdWNrZXRGb3JDbGFzc2Vzd2FzRW5jb2RlZF8QGlhEQnVja2V0Rm9yUGFja2FnZXNzdG9yYWdlXxAcWERCdWNrZXRGb3JJbnRlcmZhY2Vzc3RvcmFnZV8QD194ZF9vd25pbmdNb2RlbF8QHVhEQnVja2V0Rm9yUGFja2FnZXN3YXNFbmNvZGVkVl9vd25lcl8QG1hEQnVja2V0Rm9yRGF0YVR5cGVzc3RvcmFnZVtfdmlzaWJpbGl0eV8QGVhEQnVja2V0Rm9yQ2xhc3Nlc3N0b3JhZ2VVX25hbWVfEB9YREJ1Y2tldEZvckludGVyZmFjZXN3YXNFbmNvZGVkXxAeWERCdWNrZXRGb3JEYXRhVHlwZXN3YXNFbmNvZGVkXxAQX3VuaXF1ZUVsZW1lbnRJRIAEgQU8gQU6gAGABIAAgQU7gQU9EACABYADgASABIAAUFNZRVPTADoAOwAOADwAQQBGV05TLmtleXNaTlMub2JqZWN0c6QAPQA+AD8AQIAGgAeACIAJpABCAEMARABFgAqBASSBAx+BBGWAKF5HYWxsZXJ5U3RhdGVNT18QD0dhbGxlcnlEZXRhaWxNT1lHYWxsZXJ5TU9YQXBwRW52TU/fEBAATABNAE4ATwAhAFAAUQAjAFIAUwAOACUAVABVACgAVgBXAFgAKQApABQAXABdADEAKQBXAGAAPQBXAGMAZABlXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zZHVwbGljYXRlc18QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNzdG9yYWdlW19pc0Fic3RyYWN0gAyAMIAEgASAAoANgQEhgASADIEBI4AGgAyBASKACwgSq62Cw1dvcmRlcmVk0wA6ADsADgBpAGsARqEAaoAOoQBsgA+AKF5YRF9QU3RlcmVvdHlwZdkAIQAlAHAADgAoAHEAIwBWAHIAQgBqAFcAdgAXACkAMQBlAHpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WACoAOgAyAL4AAgAQIgBDTADoAOwAOAHwAhgBGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGakAhwCIAIkAigCLAIwAjQCOAI+AGoAegB+AIYAigCSAJoApgC2AKF8QE1hEUE1Db21wb3VuZEluZGV4ZXNfEBBYRF9QU0tfZWxlbWVudElEXxAZWERQTVVuaXF1ZW5lc3NDb25zdHJhaW50c18QGlhEX1BTS192ZXJzaW9uSGFzaE1vZGlmaWVyXxAZWERfUFNLX2ZldGNoUmVxdWVzdHNBcnJheV8QEVhEX1BTS19pc0Fic3RyYWN0XxAPWERfUFNLX3VzZXJJbmZvXxATWERfUFNLX2NsYXNzTWFwcGluZ18QFlhEX1BTS19lbnRpdHlDbGFzc05hbWXfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwCmABcAbABlAGUAZQAxAGUArQB9AGUAZQAXAGVVX3R5cGVYX2RlZmF1bHRcX2Fzc29jaWF0aW9uW19pc1JlYWRPbmx5WV9pc1N0YXRpY1lfaXNVbmlxdWVaX2lzRGVyaXZlZFpfaXNPcmRlcmVkXF9pc0NvbXBvc2l0ZVdfaXNMZWFmgACAG4AAgA8ICAgIgB2AEQgIgAAI0gA7AA4AtAC1oIAc0gC3ALgAuQC6WiRjbGFzc25hbWVYJGNsYXNzZXNeTlNNdXRhYmxlQXJyYXmjALkAuwC8V05TQXJyYXlYTlNPYmplY3TSALcAuAC+AL9fEBBYRFVNTFByb3BlcnR5SW1wpADAAMEAwgC8XxAQWERVTUxQcm9wZXJ0eUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAGwAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgA8ICAgIgB2AEggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcA1AAXAGwAZQBlAGUAMQBlAK0AfwBlAGUAFwBlgACAIIAAgA8ICAgIgB2AEwgIgAAI0gA7AA4A4gC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAGwAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgA8ICAgIgB2AFAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcA9QAXAGwAZQBlAGUAMQBlAK0AgQBlAGUAFwBlgACAI4AAgA8ICAgIgB2AFQgIgAAI0gA7AA4BAwC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAGwAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgA8ICAgIgB2AFggIgAAICN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXARcAFwBsAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgCeAAIAPCAgICIAdgBcICIAACNMAOgA7AA4BJQEmAEagoIAo0gC3ALgBKQEqXxATTlNNdXRhYmxlRGljdGlvbmFyeaMBKQErALxcTlNEaWN0aW9uYXJ53xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBLgAXAGwAZQBlAGUAMQBlAK0AhABlAGUAFwBlgACAKoAAgA8ICAgIgB2AGAgIgAAI1gAlAA4AKABWACEAIwE8AT0AFwBlABcAMYArgCyAAAiAAF8QFFhER2VuZXJpY1JlY29yZENsYXNz0gC3ALgBQwFEXVhEVU1MQ2xhc3NJbXCmAUUBRgFHAUgBSQC8XVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBTAAXAGwAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACALoAAgA8ICAgIgB2AGQgIgAAIXxAPLkdhbGxlcnlTdGF0ZU1P0gC3ALgBWwFcXxASWERVTUxTdGVyZW90eXBlSW1wpwFdAV4BXwFgAWEBYgC8XxASWERVTUxTdGVyZW90eXBlSW1wXVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w0wA6ADsADgFkAW4ARqkBZQFmAWcBaAFpAWoBawFsAW2AMYAygDOANIA1gDaAN4A4gDmpAW8BcAFxAXIBcwF0AXUBdgF3gDqAZYB8gJWArIDDgNqA8YEBCoAoWGNvbW1lbnRzVHRhZ3NfEA9yZWFkaW5nUHJvZ3Jlc3NdcHJldmlld0NvbmZpZ1lpbWFnZVVSTHNfEBFvcmlnaW5hbEltYWdlVVJMc110aHVtYm5haWxVUkxzU2dpZFtwcmV2aWV3VVJMc98QEgCbAJwAnQGDACEAnwCgAYQAIwCeAYUAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlAY0AMQBlAFcAZQGRAWUAZQBlAZUAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICIA8CIAMCIBkgDEICIA7CBMAAAABHkDGztMAOgA7AA4BmQGcAEaiAZoBm4A9gD6iAZ0BnoA/gFOAKF8QElhEX1BQcm9wU3RlcmVvdHlwZV8QElhEX1BBdHRfU3RlcmVvdHlwZdkAIQAlAaMADgAoAaQAIwBWAaUBbwGaAFcAdgAXACkAMQBlAa1fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAOoA9gAyAL4AAgAQIgEDTADoAOwAOAa8BuABGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqAG5AboBuwG8Ab0BvgG/AcCASYBKgEuATYBOgFCAUYBSgChfEBtYRF9QUFNLX2lzU3RvcmVkSW5UcnV0aEZpbGVfEBtYRF9QUFNLX3ZlcnNpb25IYXNoTW9kaWZpZXJfEBBYRF9QUFNLX3VzZXJJbmZvXxARWERfUFBTS19pc0luZGV4ZWRfEBJYRF9QUFNLX2lzT3B0aW9uYWxfEBpYRF9QUFNLX2lzU3BvdGxpZ2h0SW5kZXhlZF8QEVhEX1BQU0tfZWxlbWVudElEXxATWERfUFBTS19pc1RyYW5zaWVudN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwGdAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIA/CAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwGdAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIA/CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAeoAFwGdAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgEyAAIA/CAgICIAdgEMICIAACNMAOgA7AA4B+AH5AEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAZ0AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgD8ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXAZ0AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgD8ICAgIgB2ARQgIgAAICd8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwGdAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIA/CAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwGdAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIA/CAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwGdAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIA/CAgICIAdgEgICIAACNkAIQAlAkgADgAoAkkAIwBWAkoBbwGbAFcAdgAXACkAMQBlAlJfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAOoA+gAyAL4AAgAQIgFTTADoAOwAOAlQCXABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunAl0CXgJfAmACYQJiAmOAXIBdgF6AX4BhgGKAY4AoXxAdWERfUEF0dEtfZGVmYXVsdFZhbHVlQXNTdHJpbmdfEChYRF9QQXR0S19hbGxvd3NFeHRlcm5hbEJpbmFyeURhdGFTdG9yYWdlXxAXWERfUEF0dEtfbWluVmFsdWVTdHJpbmdfEBZYRF9QQXR0S19hdHRyaWJ1dGVUeXBlXxAXWERfUEF0dEtfbWF4VmFsdWVTdHJpbmdfEB1YRF9QQXR0S192YWx1ZVRyYW5zZm9ybWVyTmFtZV8QIFhEX1BBdHRLX3JlZ3VsYXJFeHByZXNzaW9uU3RyaW5n3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgFMICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAZ4AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgFMICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgFMICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXAZ4AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgFMICAgIgB2AWAgIgAAIEQPo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgFMICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgFMICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAZ4AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgFMICAgIgB2AWwgIgAAI0gC3ALgC1wLYXVhEUE1BdHRyaWJ1dGWmAtkC2gLbAtwC3QC8XVhEUE1BdHRyaWJ1dGVcWERQTVByb3BlcnR5XxAQWERVTUxQcm9wZXJ0eUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xASAJsAnACdAt8AIQCfAKAC4AAjAJ4C4QChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUC6QAxAGUAVwBlAZEBZgBlAGUC8QBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgGcIgAwIgGSAMggIgGYIEwAAAAETHl920wA6ADsADgL1AvgARqIBmgGbgD2APqIC+QL6gGiAc4Ao2QAhACUC/QAOACgC/gAjAFYC/wFwAZoAVwB2ABcAKQAxAGUDB18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYBlgD2ADIAvgACABAiAadMAOgA7AA4DCQMSAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioAxMDFAMVAxYDFwMYAxkDGoBqgGuAbIBugG+AcIBxgHKAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwL5AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIBoCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwL5AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIBoCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAzwAFwL5AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgG2AAIBoCAgICIAdgEMICIAACNMAOgA7AA4DSgNLAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAvkAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgGgICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXAvkAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgGgICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAvkAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgGgICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXAvkAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgGgICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXAvkAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgGgICAgIgB2ASAgIgAAI2QAhACUDmQAOACgDmgAjAFYDmwFwAZsAVwB2ABcAKQAxAGUDo18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYBlgD6ADIAvgACABAiAdNMAOgA7AA4DpQOtAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cDrgOvA7ADsQOyA7MDtIB1gHaAd4B4gHmAeoB7gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACAcwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcC+gBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACAcwgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACAcwgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABcC+gBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACAcwgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACAcwgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACAcwgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcC+gBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACAcwgICAiAHYBbCAiAAAjfEBIAmwCcAJ0EIAAhAJ8AoAQhACMAngQiAKEADgAlAKIAowAoAKQAFwAXABcAKQBCAGUAZQQqADEAZQBXAGUBkQFnAGUAZQQyAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIAKCAiAfgiADAiAZIAzCAiAfQgSV/wyTdMAOgA7AA4ENgQ5AEaiAZoBm4A9gD6iBDoEO4B/gIqAKNkAIQAlBD4ADgAoBD8AIwBWBEABcQGaAFcAdgAXACkAMQBlBEhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAfIA9gAyAL4AAgAQIgIDTADoAOwAOBEoEUwBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqARUBFUEVgRXBFgEWQRaBFuAgYCCgIOAhYCGgIeAiICJgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcEOgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACAfwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACAfwgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwR9ABcEOgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAICEgACAfwgICAiAHYBDCAiAAAjTADoAOwAOBIsEjABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwQ6AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIB/CAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFwQ6AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIB/CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwQ6AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIB/CAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwQ6AGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIB/CAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwQ6AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIB/CAgICIAdgEgICIAACNkAIQAlBNoADgAoBNsAIwBWBNwBcQGbAFcAdgAXACkAMQBlBORfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAfIA+gAyAL4AAgAQIgIvTADoAOwAOBOYE7gBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunBO8E8ATxBPIE8wT0BPWAjICOgI+AkICSgJOAlIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXBDsAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgIoICAgIgB2AVQgIgAAIUTDfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcEOwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACAiggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACAiggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwUnABcEOwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAICRgACAiggICAiAHYBYCAiAAAgRASzfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOwBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACAiggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACAiggICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcEOwBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACAiggICAiAHYBbCAiAAAjfEBIAmwCcAJ0FYwAhAJ8AoAVkACMAngVlAKEADgAlAKIAowAoAKQAFwAXABcAKQBCAGUAZQVtADEAZQBXAGUBkQFoAGUAZQV1AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIAKCAiAlwiADAiAZIA0CAiAlggS4RW54dMAOgA7AA4FeQV8AEaiAZoBm4A9gD6iBX0FfoCYgKOAKNkAIQAlBYEADgAoBYIAIwBWBYMBcgGaAFcAdgAXACkAMQBlBYtfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAlYA9gAyAL4AAgAQIgJnTADoAOwAOBY0FlgBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqAWXBZgFmQWaBZsFnAWdBZ6AmoCbgJyAnoCfgKCAoYCigCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcFfQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACAmAgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcFfQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACAmAgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwXAABcFfQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAICdgACAmAgICAiAHYBDCAiAAAjTADoAOwAOBc4FzwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwV9AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAICYCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFwV9AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAICYCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwV9AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAICYCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwV9AGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAICYCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwV9AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAICYCAgICIAdgEgICIAACNkAIQAlBh0ADgAoBh4AIwBWBh8BcgGbAFcAdgAXACkAMQBlBidfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAlYA+gAyAL4AAgAQIgKTTADoAOwAOBikGMQBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunBjIGMwY0BjUGNgY3BjiApYCmgKeAqICpgKqAq4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgKMICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBX4AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgKMICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgKMICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXBX4AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgKMICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgKMICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgKMICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBX4AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgKMICAgIgB2AWwgIgAAI3xASAJsAnACdBqQAIQCfAKAGpQAjAJ4GpgChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUGrgAxAGUAVwBlAZEBaQBlAGUGtgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgK4IgAwIgGSANQgIgK0IEj3xFsnTADoAOwAOBroGvQBGogGaAZuAPYA+oga+Br+Ar4C6gCjZACEAJQbCAA4AKAbDACMAVgbEAXMBmgBXAHYAFwApADEAZQbMXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgKyAPYAMgC+AAIAECICw0wA6ADsADgbOBtcARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgG2AbZBtoG2wbcBt0G3gbfgLGAsoCzgLWAtoC3gLiAuYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXBr4AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgK8ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXBr4AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgK8ICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcHAQAXBr4AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACAtIAAgK8ICAgIgB2AQwgIgAAI0wA6ADsADgcPBxAARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcGvgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACArwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABcGvgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACArwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcGvgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACArwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcGvgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACArwgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcGvgBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACArwgICAiAHYBICAiAAAjZACEAJQdeAA4AKAdfACMAVgdgAXMBmwBXAHYAFwApADEAZQdoXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgKyAPoAMgC+AAIAECIC70wA6ADsADgdqB3IARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpwdzB3QHdQd2B3cHeAd5gLyAvYC+gL+AwIDBgMKAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgACAAIC6CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwa/AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIC6CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIC6CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXApsAFwa/AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgGCAAIC6CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIC6CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIC6CAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwa/AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIC6CAgICIAdgFsICIAACN8QEgCbAJwAnQflACEAnwCgB+YAIwCeB+cAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlB+8AMQBlAFcAZQGRAWoAZQBlB/cAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICIDFCIAMCIBkgDYICIDECBKl+hhs0wA6ADsADgf7B/4ARqIBmgGbgD2APqIH/wgAgMaA0YAo2QAhACUIAwAOACgIBAAjAFYIBQF0AZoAVwB2ABcAKQAxAGUIDV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYDDgD2ADIAvgACABAiAx9MAOgA7AA4IDwgYAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioCBkIGggbCBwIHQgeCB8IIIDIgMmAyoDMgM2AzoDPgNCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwf/AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIDGCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwf/AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIDGCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXCEIAFwf/AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgMuAAIDGCAgICIAdgEMICIAACNMAOgA7AA4IUAhRAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXB/8AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgMYICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXB/8AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgMYICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXB/8AZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgMYICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXB/8AZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgMYICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXB/8AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgMYICAgIgB2ASAgIgAAI2QAhACUInwAOACgIoAAjAFYIoQF0AZsAVwB2ABcAKQAxAGUIqV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYDDgD6ADIAvgACABAiA0tMAOgA7AA4IqwizAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cItAi1CLYItwi4CLkIuoDTgNSA1YDWgNeA2IDZgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACA0QgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcIAABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACA0QgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACA0QgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABcIAABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACA0QgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACA0QgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACA0QgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcIAABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACA0QgICAiAHYBbCAiAAAjfEBIAmwCcAJ0JJgAhAJ8AoAknACMAngkoAKEADgAlAKIAowAoAKQAFwAXABcAKQBCAGUAZQkwADEAZQBXAGUBkQFrAGUAZQk4AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIAKCAiA3AiADAiAZIA3CAiA2wgShnCgcdMAOgA7AA4JPAk/AEaiAZoBm4A9gD6iCUAJQYDdgOiAKNkAIQAlCUQADgAoCUUAIwBWCUYBdQGaAFcAdgAXACkAMQBlCU5fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WA2oA9gAyAL4AAgAQIgN7TADoAOwAOCVAJWQBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqAlaCVsJXAldCV4JXwlgCWGA34DggOGA44DkgOWA5oDngCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcJQABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACA3QgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcJQABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACA3QgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwmDABcJQABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIDigACA3QgICAiAHYBDCAiAAAjTADoAOwAOCZEJkgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwlAAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIDdCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFwlAAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIDdCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwlAAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIDdCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwlAAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIDdCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwlAAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIDdCAgICIAdgEgICIAACNkAIQAlCeAADgAoCeEAIwBWCeIBdQGbAFcAdgAXACkAMQBlCepfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WA2oA+gAyAL4AAgAQIgOnTADoAOwAOCewJ9ABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunCfUJ9gn3CfgJ+Qn6CfuA6oDrgOyA7YDugO+A8IAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgOgICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCUEAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgOgICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgOgICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXCUEAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgOgICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgOgICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgOgICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCUEAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgOgICAgIgB2AWwgIgAAI3xASAJsAnACdCmcAIQCfAKAKaAAjAJ4KaQChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGUKcQAxAGUAVwBlAZEBbABlAGUKeQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgPMIgAwIgGSAOAgIgPIIEjx2uwTTADoAOwAOCn0KgABGogGaAZuAPYA+ogqBCoKA9ID/gCjZACEAJQqFAA4AKAqGACMAVgqHAXYBmgBXAHYAFwApADEAZQqPXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgPGAPYAMgC+AAIAECID10wA6ADsADgqRCpoARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgKmwqcCp0KngqfCqAKoQqigPaA94D4gPqA+4D8gP2A/oAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXCoEAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgPQICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCoEAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgPQICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcKxAAXCoEAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACA+YAAgPQICAgIgB2AQwgIgAAI0wA6ADsADgrSCtMARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKgQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACA9AgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKgQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACA9AgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKgQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACA9AgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcKgQBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACA9AgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKgQBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACA9AgICAiAHYBICAiAAAjZACEAJQshAA4AKAsiACMAVgsjAXYBmwBXAHYAFwApADEAZQsrXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgPGAPoAMgC+AAIAECIEBANMAOgA7AA4LLQs1AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cLNgs3CzgLOQs6CzsLPIEBAYEBA4EBBIEBBYEBB4EBCIEBCYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXCoIAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQKAAID/CAgICIAdgFUICIAACFDfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcKggBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACA/wgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcKggBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACA/wgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcKggBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgP8ICAgIgB2AWAgIgAAIEQK83xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCoIAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgP8ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCoIAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgP8ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXCoIAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgP8ICAgIgB2AWwgIgAAI3xASAJsAnACdC6oAIQCfAKALqwAjAJ4LrAChAA4AJQCiAKMAKACkABcAFwAXACkAQgBlAGULtAAxAGUAVwBlAZEBbQBlAGULvABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASACggIgQEMCIAMCIBkgDkICIEBCwgS4o6SGtMAOgA7AA4LwAvDAEaiAZoBm4A9gD6iC8QLxYEBDYEBGIAo2QAhACULyAAOACgLyQAjAFYLygF3AZoAVwB2ABcAKQAxAGUL0l8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBCoA9gAyAL4AAgAQIgQEO0wA6ADsADgvUC90ARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgL3gvfC+AL4QviC+ML5AvlgQEPgQEQgQERgQETgQEUgQEVgQEWgQEXgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcLxABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAQ0ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXC8QAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQENCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDAcAFwvEAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQESgACBAQ0ICAgIgB2AQwgIgAAI0wA6ADsADgwVDBYARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcLxABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAQ0ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXC8QAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQENCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwvEAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBDQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcLxABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAQ0ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXC8QAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQENCAgICIAdgEgICIAACNkAIQAlDGQADgAoDGUAIwBWDGYBdwGbAFcAdgAXACkAMQBlDG5fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAQqAPoAMgC+AAIAECIEBGdMAOgA7AA4McAx4AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cMeQx6DHsMfAx9DH4Mf4EBGoEBG4EBHIEBHYEBHoEBH4EBIIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXC8UAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQEYCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwvFAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEBGAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcLxQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBARgICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXC8UAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgQEYCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwvFAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBGAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcLxQBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBARgICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXC8UAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQEYCAgICIAdgFsICIAACFpkdXBsaWNhdGVz0gA7AA4M7AC1oIAc0gC3ALgM7wzwWlhEUE1FbnRpdHmnDPEM8gzzDPQM9Qz2ALxaWERQTUVudGl0eV1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QEAz4DPkM+gz7ACEM/Az9ACMM/gz/AA4AJQ0ADQEAKABWAFcNAwApACkAFA0HAF0AMQApAFcAYAA+AFcNDg0PAGVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2WADIEBN4AEgASAAoEBJoEBIYAEgAyBASOAB4AMgQMegQElCBLAxFO/0wA6ADsADg0TDRUARqEAaoAOoQ0WgQEngCjZACEAJQ0ZAA4AKA0aACMAVg0bAEMAagBXAHYAFwApADEAZQ0jXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQEkgA6ADIAvgACABAiBASjTADoAOwAODSUNLwBGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGakNMA0xDTINMw00DTUNNg03DTiBASmBASuBASyBAS6BAS+BATGBATKBATSBATWAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDTwAFw0WAGUAZQBlADEAZQCtAH0AZQBlABcAZYAAgQEqgACBAScICAgIgB2AEQgIgAAI0gA7AA4NSgC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDRYAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgQEnCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDV0AFw0WAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgQEtgACBAScICAgIgB2AEwgIgAAI0gA7AA4NawC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDRYAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgQEnCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDX4AFw0WAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgQEwgACBAScICAgIgB2AFQgIgAAI0gA7AA4NjAC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDRYAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgQEnCAgICIAdgBYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXDZ8AFw0WAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgQEzgACBAScICAgIgB2AFwgIgAAI0wA6ADsADg2tDa4ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEuABcNFgBlAGUAZQAxAGUArQCEAGUAZQAXAGWAAIAqgACBAScICAgIgB2AGAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcNwQAXDRYAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACBATaAAIEBJwgICAiAHYAZCAiAAAhfEBAuR2FsbGVyeURldGFpbE1P0wA6ADsADg3QDeUARq8QFA3RDdIN0w3UDdUN1g3XDdgN2Q3aDdsN3A3dDd4N3w3gDeEN4gFsDeSBATiBATmBATqBATuBATyBAT2BAT6BAT+BAUCBAUGBAUKBAUOBAUSBAUWBAUaBAUeBAUiBAUmAOIEBSq8QFA3mDecN6A3pDeoN6w3sDe0N7g3vDfAN8Q3yDfMN9A31DfYN9w34DfmBAUuBAWOBAXqBAZKBAamBAcCBAdiBAe+BAgaBAh2BAjSBAkuBAmKBAnuBApKBAqmBAsCBAtmBAvCBAweAKFhjb3ZlclVSTFx0b3JyZW50Q291bnRZc2l6ZUNvdW50VXRpdGxlWGxhbmd1YWdlWnVzZXJSYXRpbmdbcmF0aW5nQ291bnReZmF2b3JpdGVkQ291bnRaYXJjaGl2ZVVSTFp2aXNpYmlsaXR5WGNhdGVnb3J5WXBhZ2VDb3VudFtpc0Zhdm9yaXRlZFlwYXJlbnRVUkxYdXBsb2FkZXJYc2l6ZVR5cGVacG9zdGVkRGF0ZVhqcG5UaXRsZVZyYXRpbmffEBIAmwCcAJ0ODwAhAJ8AoA4QACMAng4RAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZQ4ZADEAZQBXAGUBkQ3RAGUAZQ4hAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQFNCIAMCIBkgQE4CAiBAUwIEtsDJnzTADoAOwAODiUOKABGogGaAZuAPYA+og4pDiqBAU6BAVmAKNkAIQAlDi0ADgAoDi4AIwBWDi8N5gGaAFcAdgAXACkAMQBlDjdfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAUuAPYAMgC+AAIAECIEBT9MAOgA7AA4OOQ5CAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioDkMORA5FDkYORw5IDkkOSoEBUIEBUYEBUoEBVIEBVYEBVoEBV4EBWIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDikAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQFOCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw4pAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEBTggICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw5sABcOKQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEBU4AAgQFOCAgICIAdgEMICIAACNMAOgA7AA4Oeg57AEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDikAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQFOCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFw4pAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEBTggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcOKQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAU4ICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDikAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQFOCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw4pAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEBTggICAiAHYBICAiAAAjZACEAJQ7JAA4AKA7KACMAVg7LDeYBmwBXAHYAFwApADEAZQ7TXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQFLgD6ADIAvgACABAiBAVrTADoAOwAODtUO3QBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunDt4O3w7gDuEO4g7jDuSBAVuBAVyBAV2BAV6BAWCBAWGBAWKAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFw4qAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAVkICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXDioAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQFZCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw4qAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEBWQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw8VABcOKgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBX4AAgQFZCAgICIAdgFgICIAACBEEsN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw4qAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBWQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcOKgBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAVkICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXDioAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQFZCAgICIAdgFsICIAACN8QEgCbAJwAnQ9RACEAnwCgD1IAIwCeD1MAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlD1sAMQBlAFcAZQGRDdIAZQBlD2MAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAWUIgAwIgGSBATkICIEBZAgS38JE5dMAOgA7AA4PZw9qAEaiAZoBm4A9gD6iD2sPbIEBZoEBcYAo2QAhACUPbwAOACgPcAAjAFYPcQ3nAZoAVwB2ABcAKQAxAGUPeV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBY4A9gAyAL4AAgAQIgQFn0wA6ADsADg97D4QARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgPhQ+GD4cPiA+JD4oPiw+MgQFogQFpgQFqgQFsgQFtgQFugQFvgQFwgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcPawBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAWYICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXD2sAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQFmCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXD64AFw9rAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQFrgACBAWYICAgIgB2AQwgIgAAI0wA6ADsADg+8D70ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcPawBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAWYICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXD2sAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQFmCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw9rAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBZggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcPawBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAWYICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXD2sAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQFmCAgICIAdgEgICIAACNkAIQAlEAsADgAoEAwAIwBWEA0N5wGbAFcAdgAXACkAMQBlEBVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAWOAPoAMgC+AAIAECIEBctMAOgA7AA4QFxAfAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cQIBAhECIQIxAkECUQJoEBc4EBdIEBdYEBdoEBd4EBeIEBeYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXD2wAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQFxCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFw9sAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEBcQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcPbABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAXEICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJwAXD2wAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAkYAAgQFxCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFw9sAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBcQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcPbABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAXEICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXD2wAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQFxCAgICIAdgFsICIAACN8QEgCbAJwAnRCSACEAnwCgEJMAIwCeEJQAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlEJwAMQBlAFcAZQGRDdMAZQBlEKQAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAXwIgAwIgGSBAToICIEBewgS3C+dINMAOgA7AA4QqBCrAEaiAZoBm4A9gD6iEKwQrYEBfYEBiIAo2QAhACUQsAAOACgQsQAjAFYQsg3oAZoAVwB2ABcAKQAxAGUQul8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBeoA9gAyAL4AAgAQIgQF+0wA6ADsADhC8EMUARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgQxhDHEMgQyRDKEMsQzBDNgQF/gQGAgQGBgQGDgQGEgQGFgQGGgQGHgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcQrABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAX0ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEKwAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQF9CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXEO8AFxCsAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQGCgACBAX0ICAgIgB2AQwgIgAAI0wA6ADsADhD9EP4ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcQrABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAX0ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEKwAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQF9CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxCsAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBfQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcQrABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAX0ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEKwAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQF9CAgICIAdgEgICIAACNkAIQAlEUwADgAoEU0AIwBWEU4N6AGbAFcAdgAXACkAMQBlEVZfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAXqAPoAMgC+AAIAECIEBidMAOgA7AA4RWBFgAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cRYRFiEWMRZBFlEWYRZ4EBioEBi4EBjIEBjYEBj4EBkIEBkYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXEK0AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQGICAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxCtAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEBiAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcQrQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAYgICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcRmAAXEK0AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAY6AAIEBiAgICAiAHYBYCAiAAAgRAljfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcQrQBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAYgICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEK0AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQGICAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxCtAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIEBiAgICAiAHYBbCAiAAAjfEBIAmwCcAJ0R1AAhAJ8AoBHVACMAnhHWAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZRHeADEAZQBXAGUBkQ3UAGUAZRHmAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQGUCIAMCIBkgQE7CAiBAZMIEinOajrTADoAOwAOEeoR7QBGogGaAZuAPYA+ohHuEe+BAZWBAaCAKNkAIQAlEfIADgAoEfMAIwBWEfQN6QGaAFcAdgAXACkAMQBlEfxfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAZKAPYAMgC+AAIAECIEBltMAOgA7AA4R/hIHAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioEggSCRIKEgsSDBINEg4SD4EBl4EBmIEBmYEBm4EBnIEBnYEBnoEBn4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEe4AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQGVCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxHuAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEBlQgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxIxABcR7gBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEBmoAAgQGVCAgICIAdgEMICIAACNMAOgA7AA4SPxJAAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEe4AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQGVCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxHuAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEBlQgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcR7gBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAZUICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEe4AZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQGVCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxHuAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEBlQgICAiAHYBICAiAAAjZACEAJRKOAA4AKBKPACMAVhKQDekBmwBXAHYAFwApADEAZRKYXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQGSgD6ADIAvgACABAiBAaHTADoAOwAOEpoSogBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunEqMSpBKlEqYSpxKoEqmBAaKBAaOBAaSBAaWBAaaBAaeBAaiAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFxHvAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAaAICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEe8AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQGgCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxHvAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEBoAgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcR7wBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQGgCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxHvAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBoAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcR7wBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAaAICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEe8AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQGgCAgICIAdgFsICIAACN8QEgCbAJwAnRMVACEAnwCgExYAIwCeExcAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlEx8AMQBlAFcAZQGRDdUAZQBlEycAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAasIgAwIgGSBATwICIEBqggSakV21tMAOgA7AA4TKxMuAEaiAZoBm4A9gD6iEy8TMIEBrIEBt4Ao2QAhACUTMwAOACgTNAAjAFYTNQ3qAZoAVwB2ABcAKQAxAGUTPV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBqYA9gAyAL4AAgAQIgQGt0wA6ADsADhM/E0gARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgTSRNKE0sTTBNNE04TTxNQgQGugQGvgQGwgQGygQGzgQG0gQG1gQG2gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTLwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAawICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEy8AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQGsCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXE3IAFxMvAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQGxgACBAawICAgIgB2AQwgIgAAI0wA6ADsADhOAE4EARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTLwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAawICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEy8AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQGsCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxMvAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBrAgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcTLwBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAawICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXEy8AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQGsCAgICIAdgEgICIAACNkAIQAlE88ADgAoE9AAIwBWE9EN6gGbAFcAdgAXACkAMQBlE9lfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAamAPoAMgC+AAIAECIEBuNMAOgA7AA4T2xPjAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cT5BPlE+YT5xPoE+kT6oEBuYEBuoEBu4EBvIEBvYEBvoEBv4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXEzAAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQKAAIEBtwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcTMABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBAbcICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEzAAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQG3CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFxMwAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBAbcICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXEzAAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQG3CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxMwAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEBtwgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcTMABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBAbcICAgIgB2AWwgIgAAI3xASAJsAnACdFFYAIQCfAKAUVwAjAJ4UWAChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUUYAAxAGUAVwBlAZEN1gBlAGUUaABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBASQICIEBwgiADAiAZIEBPQgIgQHBCBMAAAABG0vG8dMAOgA7AA4UbBRvAEaiAZoBm4A9gD6iFHAUcYEBw4EBzoAo2QAhACUUdAAOACgUdQAjAFYUdg3rAZoAVwB2ABcAKQAxAGUUfl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEBwIA9gAyAL4AAgAQIgQHE0wA6ADsADhSAFIkARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgUihSLFIwUjRSOFI8UkBSRgQHFgQHGgQHHgQHJgQHKgQHLgQHMgQHNgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcUcABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAcMICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFHAAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQHDCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFLMAFxRwAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQHIgACBAcMICAgIgB2AQwgIgAAI0wA6ADsADhTBFMIARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcUcABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAcMICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFHAAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHDCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxRwAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEBwwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcUcABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAcMICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFHAAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQHDCAgICIAdgEgICIAACNkAIQAlFRAADgAoFREAIwBWFRIN6wGbAFcAdgAXACkAMQBlFRpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAcCAPoAMgC+AAIAECIEBz9MAOgA7AA4VHBUkAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cVJRUmFScVKBUpFSoVK4EB0IEB0oEB04EB1IEB1YEB1oEB14Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcVLwAXFHEAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAdGAAIEBzggICAiAHYBVCAiAAAhTMC4w3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFHEAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQHOCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxRxAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEBzggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxGYABcUcQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBjoAAgQHOCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxRxAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEBzggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcUcQBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAc4ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFHEAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQHOCAgICIAdgFsICIAACN8QEgCbAJwAnRWYACEAnwCgFZkAIwCeFZoAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlFaIAMQBlAFcAZQGRDdcAZQBlFaoAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAdoIgAwIgGSBAT4ICIEB2QgSpOwRfNMAOgA7AA4VrhWxAEaiAZoBm4A9gD6iFbIVs4EB24EB5oAo2QAhACUVtgAOACgVtwAjAFYVuA3sAZoAVwB2ABcAKQAxAGUVwF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEB2IA9gAyAL4AAgAQIgQHc0wA6ADsADhXCFcsARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgVzBXNFc4VzxXQFdEV0hXTgQHdgQHegQHfgQHhgQHigQHjgQHkgQHlgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcVsgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAdsICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFbIAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQHbCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFfUAFxWyAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQHggACBAdsICAgIgB2AQwgIgAAI0wA6ADsADhYDFgQARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcVsgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAdsICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFbIAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHbCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxWyAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEB2wgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVsgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAdsICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFbIAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQHbCAgICIAdgEgICIAACNkAIQAlFlIADgAoFlMAIwBWFlQN7AGbAFcAdgAXACkAMQBlFlxfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAdiAPoAMgC+AAIAECIEB59MAOgA7AA4WXhZmAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cWZxZoFmkWahZrFmwWbYEB6IEB6YEB6oEB64EB7IEB7YEB7oAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXFbMAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQHmCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxWzAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEB5ggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVswBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAeYICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJwAXFbMAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAkYAAgQHmCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxWzAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEB5ggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcVswBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAeYICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFbMAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQHmCAgICIAdgFsICIAACN8QEgCbAJwAnRbZACEAnwCgFtoAIwCeFtsAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlFuMAMQBlAFcAZQGRDdgAZQBlFusAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAfEIgAwIgGSBAT8ICIEB8AgSwn3L3NMAOgA7AA4W7xbyAEaiAZoBm4A9gD6iFvMW9IEB8oEB/YAo2QAhACUW9wAOACgW+AAjAFYW+Q3tAZoAVwB2ABcAKQAxAGUXAV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEB74A9gAyAL4AAgAQIgQHz0wA6ADsADhcDFwwARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgXDRcOFw8XEBcRFxIXExcUgQH0gQH1gQH2gQH4gQH5gQH6gQH7gQH8gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcW8wBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAfIICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFvMAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQHyCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFzYAFxbzAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQH3gACBAfIICAgIgB2AQwgIgAAI0wA6ADsADhdEF0UARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcW8wBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAfIICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFvMAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQHyCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxbzAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEB8ggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW8wBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAfIICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXFvMAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQHyCAgICIAdgEgICIAACNkAIQAlF5MADgAoF5QAIwBWF5UN7QGbAFcAdgAXACkAMQBlF51fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAe+APoAMgC+AAIAECIEB/tMAOgA7AA4XnxenAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cXqBepF6oXqxesF60XroEB/4ECAIECAYECAoECA4ECBIECBYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXFvQAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQH9CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxb0AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEB/QgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW9ABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAf0ICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJwAXFvQAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAkYAAgQH9CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxb0AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEB/QgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcW9ABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAf0ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXFvQAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQH9CAgICIAdgFsICIAACN8QEgCbAJwAnRgaACEAnwCgGBsAIwCeGBwAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlGCQAMQBlAFcAZQGRDdkAZQBlGCwAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAggIgAwIgGSBAUAICIECBwgSK1zmftMAOgA7AA4YMBgzAEaiAZoBm4A9gD6iGDQYNYECCYECFIAo2QAhACUYOAAOACgYOQAjAFYYOg3uAZoAVwB2ABcAKQAxAGUYQl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECBoA9gAyAL4AAgAQIgQIK0wA6ADsADhhEGE0ARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgYThhPGFAYURhSGFMYVBhVgQILgQIMgQINgQIPgQIQgQIRgQISgQITgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcYNABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAgkICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGDQAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQIJCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXGHcAFxg0AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQIOgACBAgkICAgIgB2AQwgIgAAI0wA6ADsADhiFGIYARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcYNABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAgkICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXGDQAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQIJCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxg0AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECCQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcYNABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAgkICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGDQAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQIJCAgICIAdgEgICIAACNkAIQAlGNQADgAoGNUAIwBWGNYN7gGbAFcAdgAXACkAMQBlGN5fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAgaAPoAMgC+AAIAECIECFdMAOgA7AA4Y4BjoAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cY6RjqGOsY7BjtGO4Y74ECFoECF4ECGIECGYECGoECG4ECHIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGDUAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQIUCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxg1AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIECFAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcYNQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAhQICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcPFQAXGDUAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAV+AAIECFAgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcYNQBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAhQICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGDUAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQIUCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxg1AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIECFAgICAiAHYBbCAiAAAjfEBIAmwCcAJ0ZWwAhAJ8AoBlcACMAnhldAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZRllADEAZQBXAGUBkQ3aAGUAZRltAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQIfCIAMCIBkgQFBCAiBAh4IEjb8IzrTADoAOwAOGXEZdABGogGaAZuAPYA+ohl1GXaBAiCBAiuAKNkAIQAlGXkADgAoGXoAIwBWGXsN7wGaAFcAdgAXACkAMQBlGYNfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAh2APYAMgC+AAIAECIECIdMAOgA7AA4ZhRmOAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioGY8ZkBmRGZIZkxmUGZUZloECIoECI4ECJIECJoECJ4ECKIECKYECKoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGXUAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQIgCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxl1AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIECIAgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxm4ABcZdQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIECJYAAgQIgCAgICIAdgEMICIAACNMAOgA7AA4ZxhnHAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGXUAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQIgCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFxl1AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIECIAgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcZdQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAiAICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXUAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQIgCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxl1AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIECIAgICAiAHYBICAiAAAjZACEAJRoVAA4AKBoWACMAVhoXDe8BmwBXAHYAFwApADEAZRofXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQIdgD6ADIAvgACABAiBAizTADoAOwAOGiEaKQBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunGioaKxosGi0aLhovGjCBAi2BAi6BAi+BAjCBAjGBAjKBAjOAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxl2AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgACAAIECKwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcZdgBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBAisICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXYAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQIrCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXApsAFxl2AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgGCAAIECKwgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcZdgBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAisICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGXYAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQIrCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxl2AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIECKwgICAiAHYBbCAiAAAjfEBIAmwCcAJ0anAAhAJ8AoBqdACMAnhqeAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZRqmADEAZQBXAGUBkQ3bAGUAZRquAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQI2CIAMCIBkgQFCCAiBAjUIEv7K2DPTADoAOwAOGrIatQBGogGaAZuAPYA+ohq2GreBAjeBAkKAKNkAIQAlGroADgAoGrsAIwBWGrwN8AGaAFcAdgAXACkAMQBlGsRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAjSAPYAMgC+AAIAECIECONMAOgA7AA4axhrPAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioGtAa0RrSGtMa1BrVGtYa14ECOYECOoECO4ECPYECPoECP4ECQIECQYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGrYAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQI3CAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxq2AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIECNwgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxr5ABcatgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIECPIAAgQI3CAgICIAdgEMICIAACNMAOgA7AA4bBxsIAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGrYAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQI3CAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxq2AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIECNwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcatgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAjcICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGrYAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQI3CAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxq2AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIECNwgICAiAHYBICAiAAAjZACEAJRtWAA4AKBtXACMAVhtYDfABmwBXAHYAFwApADEAZRtgXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQI0gD6ADIAvgACABAiBAkPTADoAOwAOG2IbagBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunG2sbbBttG24bbxtwG3GBAkSBAkWBAkaBAkeBAkiBAkmBAkqAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFxq3AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAkIICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXGrcAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQJCCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxq3AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIECQggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcatwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQJCCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxq3AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIECQggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcatwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAkIICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXGrcAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQJCCAgICIAdgFsICIAACN8QEgCbAJwAnRvdACEAnwCgG94AIwCeG98AoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlG+cAMQBlAFcAZQGRDdwAZQBlG+8AZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAk0IgAwIgGSBAUMICIECTAgSuUnP8dMAOgA7AA4b8xv2AEaiAZoBm4A9gD6iG/cb+IECToECWYAo2QAhACUb+wAOACgb/AAjAFYb/Q3xAZoAVwB2ABcAKQAxAGUcBV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECS4A9gAyAL4AAgAQIgQJP0wA6ADsADhwHHBAARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgcERwSHBMcFBwVHBYcFxwYgQJQgQJRgQJSgQJUgQJVgQJWgQJXgQJYgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcb9wBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAk4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXG/cAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQJOCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHDoAFxv3AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQJTgACBAk4ICAgIgB2AQwgIgAAI0wA6ADsADhxIHEkARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcb9wBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAk4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXG/cAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQJOCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxv3AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECTggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcb9wBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAk4ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXG/cAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQJOCAgICIAdgEgICIAACNkAIQAlHJcADgAoHJgAIwBWHJkN8QGbAFcAdgAXACkAMQBlHKFfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAkuAPoAMgC+AAIAECIECWtMAOgA7AA4coxyrAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6ccrBytHK4crxywHLEcsoECW4ECXIECXYECXoECX4ECYIECYYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcE+QAXG/gAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAjYAAgQJZCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFxv4AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIECWQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcb+ABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAlkICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcFJwAXG/gAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAkYAAgQJZCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFxv4AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIECWQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcb+ABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAlkICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXG/gAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQJZCAgICIAdgFsICIAACN8QEgCbAJwAnR0eACEAnwCgHR8AIwCeHSAAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlHSgAMQBlAFcAZQGRDd0AZQBlHTAAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAmQIgAwIgGSBAUQICIECYwgS5Xw6MtMAOgA7AA4dNB03AEaiAZoBm4A9gD6iHTgdOYECZYECcIAo2QAhACUdPAAOACgdPQAjAFYdPg3yAZoAVwB2ABcAKQAxAGUdRl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECYoA9gAyAL4AAgAQIgQJm0wA6ADsADh1IHVEARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgdUh1THVQdVR1WHVcdWB1ZgQJngQJogQJpgQJrgQJsgQJtgQJugQJvgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcdOABlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAmUICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHTgAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQJlCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHXsAFx04AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQJqgACBAmUICAgIgB2AQwgIgAAI0wA6ADsADh2JHYoARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcdOABlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAmUICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHTgAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQJlCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx04AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECZQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcdOABlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAmUICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHTgAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQJlCAgICIAdgEgICIAACNkAIQAlHdgADgAoHdkAIwBWHdoN8gGbAFcAdgAXACkAMQBlHeJfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAmKAPoAMgC+AAIAECIECcdMAOgA7AA4d5B3sAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cd7R3uHe8d8B3xHfId84ECcoECdIECdYECdoECeIECeYECeoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcd9wAXHTkAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAnOAAIECcAgICAiAHYBVCAiAAAhSTk/fEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcdOQBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBAnAICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHTkAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQJwCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHiUAFx05AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQJ3gACBAnAICAgIgB2AWAgIgAAIEQMg3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHTkAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQJwCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx05AGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIECcAgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcdOQBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBAnAICAgIgB2AWwgIgAAI3xASAJsAnACdHmEAIQCfAKAeYgAjAJ4eYwChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUeawAxAGUAVwBlAZEN3gBlAGUecwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBASQICIECfQiADAiAZIEBRQgIgQJ8CBMAAAABCLMLmNMAOgA7AA4edx56AEaiAZoBm4A9gD6iHnsefIECfoECiYAo2QAhACUefwAOACgegAAjAFYegQ3zAZoAVwB2ABcAKQAxAGUeiV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECe4A9gAyAL4AAgAQIgQJ/0wA6ADsADh6LHpQARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgelR6WHpcemB6ZHpoemx6cgQKAgQKBgQKCgQKEgQKFgQKGgQKHgQKIgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABceewBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAn4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHnsAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQJ+CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXHr4AFx57AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQKDgACBAn4ICAgIgB2AQwgIgAAI0wA6ADsADh7MHs0ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABceewBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAn4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXHnsAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQJ+CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx57AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECfggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABceewBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAn4ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXHnsAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQJ+CAgICIAdgEgICIAACNkAIQAlHxsADgAoHxwAIwBWHx0N8wGbAFcAdgAXACkAMQBlHyVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAnuAPoAMgC+AAIAECIECitMAOgA7AA4fJx8vAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cfMB8xHzIfMx80HzUfNoECi4ECjIECjYECjoECj4ECkIECkYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHnwAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQKJCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx58AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIECiQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcefABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAokICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcPFQAXHnwAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAV+AAIECiQgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcefABlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAokICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXHnwAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQKJCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx58AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIECiQgICAiAHYBbCAiAAAjfEBIAmwCcAJ0fogAhAJ8AoB+jACMAnh+kAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZR+sADEAZQBXAGUBkQ3fAGUAZR+0AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQKUCIAMCIBkgQFGCAiBApMIEj0qJJTTADoAOwAOH7gfuwBGogGaAZuAPYA+oh+8H72BApWBAqCAKNkAIQAlH8AADgAoH8EAIwBWH8IN9AGaAFcAdgAXACkAMQBlH8pfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBApKAPYAMgC+AAIAECIECltMAOgA7AA4fzB/VAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioH9Yf1x/YH9kf2h/bH9wf3YECl4ECmIECmYECm4ECnIECnYECnoECn4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXH7wAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQKVCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx+8AGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEClQgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFx//ABcfvABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIECmoAAgQKVCAgICIAdgEMICIAACNMAOgA7AA4gDSAOAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXH7wAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQKVCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx+8AGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEClQgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcfvABlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBApUICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXH7wAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQKVCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFx+8AGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEClQgICAiAHYBICAiAAAjZACEAJSBcAA4AKCBdACMAViBeDfQBmwBXAHYAFwApADEAZSBmXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQKSgD6ADIAvgACABAiBAqHTADoAOwAOIGggcABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunIHEgciBzIHQgdSB2IHeBAqKBAqOBAqSBAqWBAqaBAqeBAqiAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFx+9AGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAqAICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXH70AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQKgCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx+9AGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIECoAgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcfvQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQKgCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFx+9AGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIECoAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcfvQBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAqAICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXH70AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQKgCAgICIAdgFsICIAACN8QEgCbAJwAnSDjACEAnwCgIOQAIwCeIOUAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlIO0AMQBlAFcAZQGRDeAAZQBlIPUAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAqsIgAwIgGSBAUcICIECqggSLBnP7tMAOgA7AA4g+SD8AEaiAZoBm4A9gD6iIP0g/oECrIECt4Ao2QAhACUhAQAOACghAgAjAFYhAw31AZoAVwB2ABcAKQAxAGUhC18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECqYA9gAyAL4AAgAQIgQKt0wA6ADsADiENIRYARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKghFyEYIRkhGiEbIRwhHSEegQKugQKvgQKwgQKygQKzgQK0gQK1gQK2gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcg/QBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAqwICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIP0AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQKsCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXIUAAFyD9AGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQKxgACBAqwICAgIgB2AQwgIgAAI0wA6ADsADiFOIU8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcg/QBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAqwICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIP0AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQKsCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyD9AGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIECrAgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcg/QBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAqwICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIP0AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQKsCAgICIAdgEgICIAACNkAIQAlIZ0ADgAoIZ4AIwBWIZ8N9QGbAFcAdgAXACkAMQBlIadfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAqmAPoAMgC+AAIAECIECuNMAOgA7AA4hqSGxAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6chsiGzIbQhtSG2IbchuIECuYECuoECu4ECvIECvYECvoECv4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXIP4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQKAAIECtwgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcg/gBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBArcICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIP4AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQK3CAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFyD+AGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBArcICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIP4AZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQK3CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyD+AGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIECtwgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcg/gBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBArcICAgIgB2AWwgIgAAI3xASAJsAnACdIiQAIQCfAKAiJQAjAJ4iJgChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUiLgAxAGUAVwBlAZEN4QBlAGUiNgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBASQICIECwgiADAiAZIEBSAgIgQLBCBKv1klu0wA6ADsADiI6Ij0ARqIBmgGbgD2APqIiPiI/gQLDgQLOgCjZACEAJSJCAA4AKCJDACMAViJEDfYBmgBXAHYAFwApADEAZSJMXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLAgD2ADIAvgACABAiBAsTTADoAOwAOIk4iVwBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqCJYIlkiWiJbIlwiXSJeIl+BAsWBAsaBAseBAsmBAsqBAsuBAsyBAs2AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyI+AGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIECwwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABciPgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBAsMICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcigQAXIj4AZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBAsiAAIECwwgICAiAHYBDCAiAAAjTADoAOwAOIo8ikABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyI+AGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIECwwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABciPgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBAsMICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXIj4AZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQLDCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyI+AGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIECwwgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABciPgBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBAsMICAgIgB2ASAgIgAAI2QAhACUi3gAOACgi3wAjAFYi4A32AZsAVwB2ABcAKQAxAGUi6F8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYECwIA+gAyAL4AAgAQIgQLP0wA6ADsADiLqIvIARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpyLzIvQi9SL2Ivci+CL5gQLQgQLSgQLTgQLUgQLWgQLXgQLYgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyL9ABciPwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEC0YAAgQLOCAgICIAdgFUICIAACF5SIDMvMDYvMjkgMDoxMN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyI/AGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIECzggICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABciPwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBAs4ICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcjKwAXIj8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAtWAAIECzggICAiAHYBYCAiAAAgRA4TfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABciPwBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBAs4ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXIj8AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQLOCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyI/AGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIECzggICAiAHYBbCAiAAAjfEBIAmwCcAJ0jZwAhAJ8AoCNoACMAniNpAKEADgAlAKIAowAoAKQAFwAXABcAKQBDAGUAZSNxADEAZQBXAGUBkQ3iAGUAZSN5AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEBJAgIgQLbCIAMCIBkgQFJCAiBAtoIEqcwrGbTADoAOwAOI30jgABGogGaAZuAPYA+oiOBI4KBAtyBAueAKNkAIQAlI4UADgAoI4YAIwBWI4cN9wGaAFcAdgAXACkAMQBlI49fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAtmAPYAMgC+AAIAECIEC3dMAOgA7AA4jkSOaAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioI5sjnCOdI54jnyOgI6EjooEC3oEC34EC4IEC4oEC44EC5IEC5YEC5oAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXI4EAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQLcCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyOBAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEC3AgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyPEABcjgQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEC4YAAgQLcCAgICIAdgEMICIAACNMAOgA7AA4j0iPTAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXI4EAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQLcCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFyOBAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEC3AgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcjgQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAtwICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXI4EAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQLcCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyOBAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEC3AgICAiAHYBICAiAAAjZACEAJSQhAA4AKCQiACMAViQjDfcBmwBXAHYAFwApADEAZSQrXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLZgD6ADIAvgACABAiBAujTADoAOwAOJC0kNQBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunJDYkNyQ4JDkkOiQ7JDyBAumBAuqBAuuBAuyBAu2BAu6BAu+AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyOCAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgACAAIEC5wgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcjggBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBAucICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXI4IAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQLnCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFyOCAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBAucICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXI4IAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQLnCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyOCAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEC5wgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcjggBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBAucICAgIgB2AWwgIgAAI3xASAJsAnACdJKgAIQCfAKAkqQAjAJ4kqgChAA4AJQCiAKMAKACkABcAFwAXACkAQwBlAGUksgAxAGUAVwBlAZEBbABlAGUkugBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBASQICIEC8giADAiAZIA4CAiBAvEIEuPetQTTADoAOwAOJL4kwQBGogGaAZuAPYA+oiTCJMOBAvOBAv6AKNkAIQAlJMYADgAoJMcAIwBWJMgN+AGaAFcAdgAXACkAMQBlJNBfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAvCAPYAMgC+AAIAECIEC9NMAOgA7AA4k0iTbAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioJNwk3STeJN8k4CThJOIk44EC9YEC9oEC94EC+YEC+oEC+4EC/IEC/YAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJMIAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQLzCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyTCAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEC8wgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyUFABckwgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEC+IAAgQLzCAgICIAdgEMICIAACNMAOgA7AA4lEyUUAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJMIAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQLzCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyTCAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEC8wgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABckwgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAvMICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJMIAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQLzCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyTCAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEC8wgICAiAHYBICAiAAAjZACEAJSViAA4AKCVjACMAViVkDfgBmwBXAHYAFwApADEAZSVsXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQLwgD6ADIAvgACABAiBAv/TADoAOwAOJW4ldgBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunJXcleCV5JXoleyV8JX2BAwCBAwGBAwKBAwOBAwSBAwWBAwaAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFyTDAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBAv4ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJMMAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQL+CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyTDAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEC/ggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABckwwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQL+CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyTDAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEC/ggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABckwwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAv4ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJMMAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQL+CAgICIAdgFsICIAACN8QEgCbAJwAnSXpACEAnwCgJeoAIwCeJesAoQAOACUAogCjACgApAAXABcAFwApAEMAZQBlJfMAMQBlAFcAZQGRDeQAZQBlJfsAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQEkCAiBAwkIgAwIgGSBAUoICIEDCAgTAAAAASVrX+bTADoAOwAOJf8mAgBGogGaAZuAPYA+oiYDJgSBAwqBAxWAKNkAIQAlJgcADgAoJggAIwBWJgkN+QGaAFcAdgAXACkAMQBlJhFfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAweAPYAMgC+AAIAECIEDC9MAOgA7AA4mEyYcAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioJh0mHiYfJiAmISYiJiMmJIEDDIEDDYEDDoEDEIEDEYEDEoEDE4EDFIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJgMAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQMKCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyYDAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEDCggICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyZGABcmAwBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEDD4AAgQMKCAgICIAdgEMICIAACNMAOgA7AA4mVCZVAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJgMAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQMKCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyYDAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEDCggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcmAwBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBAwoICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJgMAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQMKCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyYDAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEDCggICAiAHYBICAiAAAjZACEAJSajAA4AKCakACMAVialDfkBmwBXAHYAFwApADEAZSatXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQMHgD6ADIAvgACABAiBAxbTADoAOwAOJq8mtwBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunJrgmuSa6JrsmvCa9Jr6BAxeBAxiBAxmBAxqBAxuBAxyBAx2AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXFS8AFyYEAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQHRgACBAxUICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJgQAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQMVCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyYEAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDFQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFxGYABcmBABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBjoAAgQMVCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyYEAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEDFQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcmBABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBAxUICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJgQAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQMVCAgICIAdgFsICIAACNIAOwAOJyoAtaCAHN8QECctJy4nLycwACEnMScyACMnMyc0AA4AJSc1JzYAKABWAFcnOAApACkAFCc8AF0AMQApAFcAYAA/AFcnQydEAGVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2WADIEDMoAEgASAAoEDIYEBIYAEgAyBASOACIAMgQRkgQMgCBKIZBBe0wA6ADsADidIJ0oARqEAaoAOoSdLgQMigCjZACEAJSdOAA4AKCdPACMAVidQAEQAagBXAHYAFwApADEAZSdYXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQMfgA6ADIAvgACABAiBAyPTADoAOwAOJ1onZABGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGaknZSdmJ2cnaCdpJ2onaydsJ22BAySBAyaBAyeBAymBAyqBAyyBAy2BAy+BAzCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJ3EAFydLAGUAZQBlADEAZQCtAH0AZQBlABcAZYAAgQMlgACBAyIICAgIgB2AEQgIgAAI0gA7AA4nfwC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJ0sAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgQMiCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJ5IAFydLAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgQMogACBAyIICAgIgB2AEwgIgAAI0gA7AA4noAC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXJ0sAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgQMiCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJ7MAFydLAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgQMrgACBAyIICAgIgB2AFQgIgAAI0gA7AA4nwQC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXJ0sAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgQMiCAgICIAdgBYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXJ9QAFydLAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgQMugACBAyIICAgIgB2AFwgIgAAI0wA6ADsADifiJ+MARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEuABcnSwBlAGUAZQAxAGUArQCEAGUAZQAXAGWAAIAqgACBAyIICAgIgB2AGAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcn9gAXJ0sAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACBAzGAAIEDIggICAiAHYAZCAiAAAhaLkdhbGxlcnlNT9MAOgA7AA4oBSgTAEatDeQN2ygIDd8oCg3UAWwoDQ3VDdwoECgRDdGBAUqBAUKBAzOBAUaBAzSBATuAOIEDNYEBPIEBQ4EDNoEDN4EBOK0oFCgVKBYoFygYKBkoGigbKBwoHSgeKB8oIIEDOIEDT4EDZoEDfoEDlYEDrIEDw4ED2oED8YEECIEEH4EENoEETYAoWnBvc3RlZERhdGVcbGFzdE9wZW5EYXRlVXRva2VuWmdhbGxlcnlVUkxadGFnU3RyaW5nc98QEgCbAJwAnSgoACEAnwCgKCkAIwCeKCoAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlKDIAMQBlAFcAZQGRDeQAZQBlKDoAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBAzoIgAwIgGSBAUoICIEDOQgSyW9B69MAOgA7AA4oPihBAEaiAZoBm4A9gD6iKEIoQ4EDO4EDRoAo2QAhACUoRgAOACgoRwAjAFYoSCgUAZoAVwB2ABcAKQAxAGUoUF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDOIA9gAyAL4AAgAQIgQM80wA6ADsADihSKFsARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgoXChdKF4oXyhgKGEoYihjgQM9gQM+gQM/gQNBgQNCgQNDgQNEgQNFgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcoQgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBAzsICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKEIAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQM7CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXKIUAFyhCAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQNAgACBAzsICAgIgB2AQwgIgAAI0wA6ADsADiiTKJQARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcoQgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBAzsICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKEIAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQM7CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyhCAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEDOwgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcoQgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBAzsICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKEIAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQM7CAgICIAdgEgICIAACNkAIQAlKOIADgAoKOMAIwBWKOQoFAGbAFcAdgAXACkAMQBlKOxfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAziAPoAMgC+AAIAECIEDR9MAOgA7AA4o7ij2AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6co9yj4KPko+ij7KPwo/YEDSIEDSYEDSoEDS4EDTIEDTYEDToAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcVLwAXKEMAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAdGAAIEDRggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcoQwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBA0YICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKEMAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQNGCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXEZgAFyhDAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQGOgACBA0YICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKEMAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQNGCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyhDAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEDRggICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcoQwBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBA0YICAgIgB2AWwgIgAAI3xASAJsAnACdKWkAIQCfAKApagAjAJ4pawChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUpcwAxAGUAVwBlAZEN2wBlAGUpewBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAx8ICIEDUQiADAiAZIEBQggIgQNQCBMAAAABJehGndMAOgA7AA4pfymCAEaiAZoBm4A9gD6iKYMphIEDUoEDXYAo2QAhACUphwAOACgpiAAjAFYpiSgVAZoAVwB2ABcAKQAxAGUpkV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDT4A9gAyAL4AAgAQIgQNT0wA6ADsADimTKZwARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgpnSmeKZ8poCmhKaIpoymkgQNUgQNVgQNWgQNYgQNZgQNagQNbgQNcgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcpgwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBA1IICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKYMAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQNSCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXKcYAFymDAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQNXgACBA1IICAgIgB2AQwgIgAAI0wA6ADsADinUKdUARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcpgwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBA1IICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKYMAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAJYAAgQNSCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFymDAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEDUggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcpgwBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBA1IICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKYMAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQNSCAgICIAdgEgICIAACNkAIQAlKiMADgAoKiQAIwBWKiUoFQGbAFcAdgAXACkAMQBlKi1fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA0+APoAMgC+AAIAECIEDXtMAOgA7AA4qLyo3AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6cqOCo5KjoqOyo8Kj0qPoEDX4EDYIEDYYEDYoEDY4EDZIEDZYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAGgAXKYQAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACBAQKAAIEDXQgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcphABlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBA10ICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKYQAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQNdCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFymEAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBA10ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKYQAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQNdCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFymEAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEDXQgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcphABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBA10ICAgIgB2AWwgIgAAI3xASAJsAnACdKqoAIQCfAKAqqwAjAJ4qrAChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUqtAAxAGUAVwBlAZEoCABlAGUqvABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAx8ICIEDaAiADAiAZIEDMwgIgQNnCBLD8ov20wA6ADsADirAKsMARqIBmgGbgD2APqIqxCrFgQNpgQN0gCjZACEAJSrIAA4AKCrJACMAVirKKBYBmgBXAHYAFwApADEAZSrSXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQNmgD2ADIAvgACABAiBA2rTADoAOwAOKtQq3QBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqCreKt8q4CrhKuIq4yrkKuWBA2uBA2yBA22BA2+BA3CBA3GBA3KBA3OAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyrEAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDaQgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcqxABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBA2kICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcrBwAXKsQAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBA26AAIEDaQgICAiAHYBDCAiAAAjTADoAOwAOKxUrFgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyrEAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEDaQgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcqxABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBA2kICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXKsQAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQNpCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyrEAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEDaQgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcqxABlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBA2kICAgIgB2ASAgIgAAI2QAhACUrZAAOACgrZQAjAFYrZigWAZsAVwB2ABcAKQAxAGUrbl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDZoA+gAyAL4AAgAQIgQN10wA6ADsADitwK3gARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpyt5K3oreyt8K30rfit/gQN2gQN4gQN5gQN6gQN7gQN8gQN9gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyuDABcqxQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEDd4AAgQN0CAgICIAdgFUICIAACF5SIDMvMDYvMjkgMDoxMd8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFyrFAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEDdAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcqxQBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA3QICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcjKwAXKsUAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAtWAAIEDdAgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcqxQBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBA3QICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXKsUAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQN0CAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFyrFAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIEDdAgICAiAHYBbCAiAAAjfEBIAmwCcAJ0r7AAhAJ8AoCvtACMAnivuAKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZSv2ADEAZQBXAGUBkQ3fAGUAZSv+AGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDHwgIgQOACIAMCIBkgQFGCAiBA38IEq7LSQzTADoAOwAOLAIsBQBGogGaAZuAPYA+oiwGLAeBA4GBA4yAKNkAIQAlLAoADgAoLAsAIwBWLAwoFwGaAFcAdgAXACkAMQBlLBRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA36APYAMgC+AAIAECIEDgtMAOgA7AA4sFiwfAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioLCAsISwiLCMsJCwlLCYsJ4EDg4EDhIEDhYEDh4EDiIEDiYEDioEDi4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLAYAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQOBCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFywGAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEDgQgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyxJABcsBgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEDhoAAgQOBCAgICIAdgEMICIAACNMAOgA7AA4sVyxYAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLAYAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQOBCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFywGAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEDgQgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcsBgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBA4EICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLAYAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQOBCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFywGAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEDgQgICAiAHYBICAiAAAjZACEAJSymAA4AKCynACMAViyoKBcBmwBXAHYAFwApADEAZSywXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQN+gD6ADIAvgACABAiBA43TADoAOwAOLLIsugBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunLLssvCy9LL4svyzALMGBA46BA4+BA5CBA5GBA5KBA5OBA5SAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFywHAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgACAAIEDjAgICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcsBwBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBA4wICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLAcAZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQOMCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXC24AFywHAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgQEGgACBA4wICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLAcAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQOMCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFywHAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEDjAgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcsBwBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBA4wICAgIgB2AWwgIgAAI3xASAJsAnACdLS0AIQCfAKAtLgAjAJ4tLwChAA4AJQCiAKMAKACkABcAFwAXACkARABlAGUtNwAxAGUAVwBlAZEoCgBlAGUtPwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBAx8ICIEDlwiADAiAZIEDNAgIgQOWCBLhLFaW0wA6ADsADi1DLUYARqIBmgGbgD2APqItRy1IgQOYgQOjgCjZACEAJS1LAA4AKC1MACMAVi1NKBgBmgBXAHYAFwApADEAZS1VXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQOVgD2ADIAvgACABAiBA5nTADoAOwAOLVctYABGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqC1hLWItYy1kLWUtZi1nLWiBA5qBA5uBA5yBA56BA5+BA6CBA6GBA6KAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy1HAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDmAgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABctRwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBA5gICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABctigAXLUcAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBA52AAIEDmAgICAiAHYBDCAiAAAjTADoAOwAOLZgtmQBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy1HAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEDmAgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABctRwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBA5gICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLUcAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQOYCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy1HAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEDmAgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABctRwBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBA5gICAgIgB2ASAgIgAAI2QAhACUt5wAOACgt6AAjAFYt6SgYAZsAVwB2ABcAKQAxAGUt8V8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDlYA+gAyAL4AAgAQIgQOk0wA6ADsADi3zLfsARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpy38Lf0t/i3/LgAuAS4CgQOlgQOmgQOngQOogQOpgQOqgQOrgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABctSABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBA6MICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLUgAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQOjCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy1IAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDowgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFyMrABctSABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEC1YAAgQOjCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy1IAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEDowgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABctSABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBA6MICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLUgAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQOjCAgICIAdgFsICIAACN8QEgCbAJwAnS5uACEAnwCgLm8AIwCeLnAAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlLngAMQBlAFcAZQGRDdQAZQBlLoAAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBA64IgAwIgGSBATsICIEDrQgTAAAAASWXzaTTADoAOwAOLoQuhwBGogGaAZuAPYA+oi6ILomBA6+BA7qAKNkAIQAlLowADgAoLo0AIwBWLo4oGQGaAFcAdgAXACkAMQBlLpZfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA6yAPYAMgC+AAIAECIEDsNMAOgA7AA4umC6hAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioLqIuoy6kLqUupi6nLqguqYEDsYEDsoEDs4EDtYEDtoEDt4EDuIEDuYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLogAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQOvCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6IAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEDrwgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFy7LABcuiABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEDtIAAgQOvCAgICIAdgEMICIAACNMAOgA7AA4u2S7aAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLogAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQOvCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy6IAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEDrwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcuiABlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBA68ICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLogAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQOvCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy6IAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEDrwgICAiAHYBICAiAAAjZACEAJS8oAA4AKC8pACMAVi8qKBkBmwBXAHYAFwApADEAZS8yXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQOsgD6ADIAvgACABAiBA7vTADoAOwAOLzQvPABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunLz0vPi8/L0AvQS9CL0OBA7yBA72BA76BA7+BA8CBA8GBA8KAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFy6JAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBA7oICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXLokAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQO6CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6JAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEDuggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcuiQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQO6CAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy6JAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEDuggICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcuiQBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBA7oICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXLokAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQO6CAgICIAdgFsICIAACN8QEgCbAJwAnS+vACEAnwCgL7AAIwCeL7EAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlL7kAMQBlAFcAZQGRAWwAZQBlL8EAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBA8UIgAwIgGSAOAgIgQPECBJDDi5g0wA6ADsADi/FL8gARqIBmgGbgD2APqIvyS/KgQPGgQPRgCjZACEAJS/NAA4AKC/OACMAVi/PKBoBmgBXAHYAFwApADEAZS/XXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQPDgD2ADIAvgACABAiBA8fTADoAOwAOL9kv4gBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqC/jL+Qv5S/mL+cv6C/pL+qBA8iBA8mBA8qBA8yBA82BA86BA8+BA9CAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy/JAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEDxggICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcvyQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBA8YICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcwDAAXL8kAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBA8uAAIEDxggICAiAHYBDCAiAAAjTADoAOwAOMBowGwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy/JAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEDxggICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcvyQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIAlgACBA8YICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXL8kAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQPGCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy/JAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEDxggICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcvyQBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBA8YICAgIgB2ASAgIgAAI2QAhACUwaQAOACgwagAjAFYwaygaAZsAVwB2ABcAKQAxAGUwc18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEDw4A+gAyAL4AAgAQIgQPS0wA6ADsADjB1MH0ARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpzB+MH8wgDCBMIIwgzCEgQPTgQPUgQPVgQPWgQPXgQPYgQPZgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAaABcvygBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIEBAoAAgQPRCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFy/KAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIED0QgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcvygBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA9EICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLbgAXL8oAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAQaAAIED0QgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcvygBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBA9EICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXL8oAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQPRCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFy/KAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIED0QgICAiAHYBbCAiAAAjfEBIAmwCcAJ0w8AAhAJ8AoDDxACMAnjDyAKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZTD6ADEAZQBXAGUBkSgNAGUAZTECAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDHwgIgQPcCIAMCIBkgQM1CAiBA9sIEo5OWe7TADoAOwAOMQYxCQBGogGaAZuAPYA+ojEKMQuBA92BA+iAKNkAIQAlMQ4ADgAoMQ8AIwBWMRAoGwGaAFcAdgAXACkAMQBlMRhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA9qAPYAMgC+AAIAECIED3tMAOgA7AA4xGjEjAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioMSQxJTEmMScxKDEpMSoxK4ED34ED4IED4YED44ED5IED5YED5oED54Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMQoAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQPdCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzEKAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIED3QgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzFNABcxCgBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIED4oAAgQPdCAgICIAdgEMICIAACNMAOgA7AA4xWzFcAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMQoAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQPdCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzEKAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIED3QgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcxCgBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBA90ICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMQoAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQPdCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzEKAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIED3QgICAiAHYBICAiAAAjZACEAJTGqAA4AKDGrACMAVjGsKBsBmwBXAHYAFwApADEAZTG0XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQPagD6ADIAvgACABAiBA+nTADoAOwAOMbYxvgBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunMb8xwDHBMcIxwzHEMcWBA+qBA+uBA+yBA+2BA+6BA++BA/CAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFzELAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBA+gICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMQsAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQPoCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzELAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIED6AgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwtuABcxCwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBBoAAgQPoCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzELAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIED6AgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcxCwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBA+gICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMQsAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQPoCAgICIAdgFsICIAACN8QEgCbAJwAnTIxACEAnwCgMjIAIwCeMjMAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlMjsAMQBlAFcAZQGRDdUAZQBlMkMAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBA/MIgAwIgGSBATwICIED8ggSYqDXpNMAOgA7AA4yRzJKAEaiAZoBm4A9gD6iMksyTIED9IED/4Ao2QAhACUyTwAOACgyUAAjAFYyUSgcAZoAVwB2ABcAKQAxAGUyWV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYED8YA9gAyAL4AAgAQIgQP10wA6ADsADjJbMmQARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKgyZTJmMmcyaDJpMmoyazJsgQP2gQP3gQP4gQP6gQP7gQP8gQP9gQP+gCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcySwBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBA/QICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMksAZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQP0CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXMo4AFzJLAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQP5gACBA/QICAgIgB2AQwgIgAAI0wA6ADsADjKcMp0ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABcySwBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBA/QICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXMksAZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQP0CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzJLAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIED9AgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcySwBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBA/QICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXMksAZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQP0CAgICIAdgEgICIAACNkAIQAlMusADgAoMuwAIwBWMu0oHAGbAFcAdgAXACkAMQBlMvVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBA/GAPoAMgC+AAIAECIEEANMAOgA7AA4y9zL/AEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6czADMBMwIzAzMEMwUzBoEEAYEEAoEEA4EEBIEEBYEEBoEEB4Ao3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMkwAZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQP/CAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzJMAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIED/wgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcyTABlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBA/8ICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcLbgAXMkwAZQBlAGUAMQBlAK0CWABlAGUAFwBlgACBAQaAAIED/wgICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABcyTABlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBA/8ICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXMkwAZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQP/CAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzJMAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIED/wgICAiAHYBbCAiAAAjfEBIAmwCcAJ0zcgAhAJ8AoDNzACMAnjN0AKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZTN8ADEAZQBXAGUBkQ3cAGUAZTOEAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDHwgIgQQKCIAMCIBkgQFDCAiBBAkIEtab1pzTADoAOwAOM4gziwBGogGaAZuAPYA+ojOMM42BBAuBBBaAKNkAIQAlM5AADgAoM5EAIwBWM5IoHQGaAFcAdgAXACkAMQBlM5pfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBAiAPYAMgC+AAIAECIEEDNMAOgA7AA4znDOlAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioM6YzpzOoM6kzqjOrM6wzrYEEDYEEDoEED4EEEYEEEoEEE4EEFIEEFYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXM4wAZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQQLCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzOMAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEECwgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzPPABczjABlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEEEIAAgQQLCAgICIAdgEMICIAACNMAOgA7AA4z3TPeAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXM4wAZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQQLCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzOMAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgCWAAIEECwgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABczjABlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBBAsICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM4wAZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQQLCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzOMAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEECwgICAiAHYBICAiAAAjZACEAJTQsAA4AKDQtACMAVjQuKB0BmwBXAHYAFwApADEAZTQ2XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQQIgD6ADIAvgACABAiBBBfTADoAOwAONDg0QABGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunNEE0QjRDNEQ0RTRGNEeBBBiBBBmBBBqBBBuBBByBBB2BBB6AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXBPkAFzONAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgI2AAIEEFggICAiAHYBVCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABczjQBlAGUAZQAxAGUArQJWAGUAZQAXAGWAAIAlgACBBBYICAgIgB2AVggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM40AZQBlAGUAMQBlAK0CVwBlAGUAFwBlgACAAIAAgQQWCAgICIAdgFcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXBScAFzONAGUAZQBlADEAZQCtAlgAZQBlABcAZYAAgJGAAIEEFggICAiAHYBYCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABczjQBlAGUAZQAxAGUArQJZAGUAZQAXAGWAAIAAgACBBBYICAgIgB2AWQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXM40AZQBlAGUAMQBlAK0CWgBlAGUAFwBlgACAAIAAgQQWCAgICIAdgFoICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzONAGUAZQBlADEAZQCtAlsAZQBlABcAZYAAgACAAIEEFggICAiAHYBbCAiAAAjfEBIAmwCcAJ00swAhAJ8AoDS0ACMAnjS1AKEADgAlAKIAowAoAKQAFwAXABcAKQBEAGUAZTS9ADEAZQBXAGUBkSgQAGUAZTTFAGVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIEDHwgIgQQhCIAMCIBkgQM2CAiBBCAIEi93jUzTADoAOwAONMk0zABGogGaAZuAPYA+ojTNNM6BBCKBBC2AKNkAIQAlNNEADgAoNNIAIwBWNNMoHgGaAFcAdgAXACkAMQBlNNtfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBB+APYAMgC+AAIAECIEEI9MAOgA7AA403TTmAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioNOc06DTpNOo06zTsNO007oEEJIEEJYEEJoEEKIEEKYEEKoEEK4EELIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNM0AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQQiCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzTNAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEEIggICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzUQABc0zQBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEEJ4AAgQQiCAgICIAdgEMICIAACNMAOgA7AA41HjUfAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNM0AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQQiCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFzTNAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEEIggICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc0zQBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBBCIICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNM0AZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQQiCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzTNAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEEIggICAiAHYBICAiAAAjZACEAJTVtAA4AKDVuACMAVjVvKB4BmwBXAHYAFwApADEAZTV3XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQQfgD6ADIAvgACABAiBBC7TADoAOwAONXk1gQBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunNYI1gzWENYU1hjWHNYiBBC+BBDCBBDGBBDKBBDOBBDSBBDWAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFzTOAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBBC0ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNM4AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQQtCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzTOAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEELQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw8VABc0zgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBX4AAgQQtCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzTOAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEELQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc0zgBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBC0ICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNM4AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQQtCAgICIAdgFsICIAACN8QEgCbAJwAnTX0ACEAnwCgNfUAIwCeNfYAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlNf4AMQBlAFcAZQGRKBEAZQBlNgYAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBBDgIgAwIgGSBAzcICIEENwgS6NUHrNMAOgA7AA42CjYNAEaiAZoBm4A9gD6iNg42D4EEOYEERIAo2QAhACU2EgAOACg2EwAjAFY2FCgfAZoAVwB2ABcAKQAxAGU2HF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEENoA9gAyAL4AAgAQIgQQ60wA6ADsADjYeNicARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKg2KDYpNio2KzYsNi02LjYvgQQ7gQQ8gQQ9gQQ/gQRAgQRBgQRCgQRDgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc2DgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBBDkICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNg4AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQQ5CAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXNlEAFzYOAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQQ+gACBBDkICAgIgB2AQwgIgAAI0wA6ADsADjZfNmAARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc2DgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBBDkICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXNg4AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQQ5CAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzYOAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEEOQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2DgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBBDkICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXNg4AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQQ5CAgICIAdgEgICIAACNkAIQAlNq4ADgAoNq8AIwBWNrAoHwGbAFcAdgAXACkAMQBlNrhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBDaAPoAMgC+AAIAECIEERdMAOgA7AA42ujbCAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6c2wzbENsU2xjbHNsg2yYEERoEER4EESIEESYEESoEES4EETIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNg8AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQRECAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzYPAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEERAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2DwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBBEQICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXNg8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgQRECAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzYPAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEERAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc2DwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBEQICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXNg8AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQRECAgICIAdgFsICIAACN8QEgCbAJwAnTc1ACEAnwCgNzYAIwCeNzcAoQAOACUAogCjACgApAAXABcAFwApAEQAZQBlNz8AMQBlAFcAZQGRDdEAZQBlN0cAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQMfCAiBBE8IgAwIgGSBATgICIEETggTAAAAASCad27TADoAOwAON0s3TgBGogGaAZuAPYA+ojdPN1CBBFCBBFuAKNkAIQAlN1MADgAoN1QAIwBWN1UoIAGaAFcAdgAXACkAMQBlN11fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBE2APYAMgC+AAIAECIEEUdMAOgA7AA43XzdoAEaoAbABsQGyAbMBtAG1AbYBt4BBgEKAQ4BEgEWARoBHgEioN2k3ajdrN2w3bTduN283cIEEUoEEU4EEVIEEVoEEV4EEWIEEWYEEWoAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXN08AZQBlAGUAMQBlAK0BsABlAGUAFwBlgACAJYAAgQRQCAgICIAdgEEICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzdPAGUAZQBlADEAZQCtAbEAZQBlABcAZYAAgACAAIEEUAgICAiAHYBCCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFzeSABc3TwBlAGUAZQAxAGUArQGyAGUAZQAXAGWAAIEEVYAAgQRQCAgICIAdgEMICIAACNMAOgA7AA43oDehAEagoIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXN08AZQBlAGUAMQBlAK0BswBlAGUAFwBlgACAJYAAgQRQCAgICIAdgEQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAgwAFzdPAGUAZQBlADEAZQCtAbQAZQBlABcAZYAAgE+AAIEEUAgICAiAHYBFCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc3TwBlAGUAZQAxAGUArQG1AGUAZQAXAGWAAIAlgACBBFAICAgIgB2ARggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXN08AZQBlAGUAMQBlAK0BtgBlAGUAFwBlgACAAIAAgQRQCAgICIAdgEcICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzdPAGUAZQBlADEAZQCtAbcAZQBlABcAZYAAgCWAAIEEUAgICAiAHYBICAiAAAjZACEAJTfvAA4AKDfwACMAVjfxKCABmwBXAHYAFwApADEAZTf5XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQRNgD6ADIAvgACABAiBBFzTADoAOwAON/s4AwBGpwJVAlYCVwJYAlkCWgJbgFWAVoBXgFiAWYBagFunOAQ4BTgGOAc4CDgJOAqBBF2BBF6BBF+BBGCBBGGBBGKBBGOAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABoAFzdQAGUAZQBlADEAZQCtAlUAZQBlABcAZYAAgQECgACBBFsICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXN1AAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQRbCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzdQAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEWwgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFw8VABc3UABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIEBX4AAgQRbCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzdQAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEEWwgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc3UABlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBFsICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXN1AAZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQRbCAgICIAdgFsICIAACNIAOwAOOHYAtaCAHN8QEDh5OHo4ezh8ACE4fTh+ACM4fziAAA4AJTiBOIIAKABWAFc4hAApACkAFDiIAF0AMQApAFcAYABAAFc4jziQAGVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2WADIEEeIAEgASAAoEEZ4EBIYAEgAyBASOACYAMgQU5gQRmCBJNlg0R0wA6ADsADjiUOJYARqEAaoAOoTiXgQRogCjZACEAJTiaAA4AKDibACMAVjicAEUAagBXAHYAFwApADEAZTikXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQRlgA6ADIAvgACABAiBBGnTADoAOwAOOKY4sABGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGak4sTiyOLM4tDi1OLY4tzi4OLmBBGqBBGyBBG2BBG+BBHCBBHKBBHOBBHWBBHaAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXOL0AFziXAGUAZQBlADEAZQCtAH0AZQBlABcAZYAAgQRrgACBBGgICAgIgB2AEQgIgAAI0gA7AA44ywC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOJcAZQBlAGUAMQBlAK0AfgBlAGUAFwBlgACAAIAAgQRoCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXON4AFziXAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgQRugACBBGgICAgIgB2AEwgIgAAI0gA7AA447AC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOJcAZQBlAGUAMQBlAK0AgABlAGUAFwBlgACAAIAAgQRoCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXOP8AFziXAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgQRxgACBBGgICAgIgB2AFQgIgAAI0gA7AA45DQC1oIAc3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOJcAZQBlAGUAMQBlAK0AggBlAGUAFwBlgACAJYAAgQRoCAgICIAdgBYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXOSAAFziXAGUAZQBlADEAZQCtAIMAZQBlABcAZYAAgQR0gACBBGgICAgIgB2AFwgIgAAI0wA6ADsADjkuOS8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEuABc4lwBlAGUAZQAxAGUArQCEAGUAZQAXAGWAAIAqgACBBGgICAgIgB2AGAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc5QgAXOJcAZQBlAGUAMQBlAK0AhQBlAGUAFwBlgACBBHeAAIEEaAgICAiAHYAZCAiAAAhZLkFwcEVudk1P0wA6ADsADjlROVoARqg5UjlTOVQ5VTlWOVc5WDlZgQR5gQR6gQR7gQR8gQR9gQR+gQR/gQSAqDlbOVw5XTleOV85YDlhOWKBBIGBBJiBBK+BBMaBBN2BBPSBBQuBBSKAKF8QEHF1aWNrU2VhcmNoV29yZHNfEA9oaXN0b3J5S2V5d29yZHNcZ2xvYmFsRmlsdGVyXXdhdGNoZWRGaWx0ZXJcc2VhcmNoRmlsdGVyXXRhZ1RyYW5zbGF0b3JXc2V0dGluZ1R1c2Vy3xASAJsAnACdOW0AIQCfAKA5bgAjAJ45bwChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU5dwAxAGUAVwBlAZE5UgBlAGU5fwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEEgwiADAiAZIEEeQgIgQSCCBK5ebCZ0wA6ADsADjmDOYYARqIBmgGbgD2APqI5hzmIgQSEgQSPgCjZACEAJTmLAA4AKDmMACMAVjmNOVsBmgBXAHYAFwApADEAZTmVXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQSBgD2ADIAvgACABAiBBIXTADoAOwAOOZc5oABGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqDmhOaI5ozmkOaU5pjmnOaiBBIaBBIeBBIiBBIqBBIuBBIyBBI2BBI6AKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzmHAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEhAgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5hwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBIQICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc5ygAXOYcAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBImAAIEEhAgICAiAHYBDCAiAAAjTADoAOwAOOdg52QBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzmHAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEEhAgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc5hwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBIQICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOYcAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQSECAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzmHAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEEhAgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc5hwBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBIQICAgIgB2ASAgIgAAI2QAhACU6JwAOACg6KAAjAFY6KTlbAZsAVwB2ABcAKQAxAGU6MV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEgYA+gAyAL4AAgAQIgQSQ0wA6ADsADjozOjsARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpzo8Oj06Pjo/OkA6QTpCgQSRgQSSgQSTgQSUgQSVgQSWgQSXgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5iABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBI8ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOYgAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQSPCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzmIAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEjwgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc5iABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBI8ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOYgAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQSPCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzmIAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEEjwgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc5iABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBI8ICAgIgB2AWwgIgAAI3xASAJsAnACdOq4AIQCfAKA6rwAjAJ46sAChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU6uAAxAGUAVwBlAZE5UwBlAGU6wABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEEmgiADAiAZIEEeggIgQSZCBJGtS8B0wA6ADsADjrEOscARqIBmgGbgD2APqI6yDrJgQSbgQSmgCjZACEAJTrMAA4AKDrNACMAVjrOOVwBmgBXAHYAFwApADEAZTrWXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQSYgD2ADIAvgACABAiBBJzTADoAOwAOOtg64QBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqDriOuM65DrlOuY65zroOumBBJ2BBJ6BBJ+BBKGBBKKBBKOBBKSBBKWAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzrIAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEmwgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6yABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBJsICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc7CwAXOsgAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBKCAAIEEmwgICAiAHYBDCAiAAAjTADoAOwAOOxk7GgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzrIAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEEmwgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc6yABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBJsICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOsgAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQSbCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzrIAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEEmwgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc6yABlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBJsICAgIgB2ASAgIgAAI2QAhACU7aAAOACg7aQAjAFY7ajlcAZsAVwB2ABcAKQAxAGU7cl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEmIA+gAyAL4AAgAQIgQSn0wA6ADsADjt0O3wARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpzt9O347fzuAO4E7gjuDgQSogQSpgQSqgQSrgQSsgQStgQSugCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6yQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBKYICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXOskAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQSmCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzrJAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEpggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc6yQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBKYICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXOskAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQSmCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzrJAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEEpggICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc6yQBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBKYICAgIgB2AWwgIgAAI3xASAJsAnACdO+8AIQCfAKA78AAjAJ478QChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU7+QAxAGUAVwBlAZE5VABlAGU8AQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEEsQiADAiAZIEEewgIgQSwCBJ9AWc60wA6ADsADjwFPAgARqIBmgGbgD2APqI8CTwKgQSygQS9gCjZACEAJTwNAA4AKDwOACMAVjwPOV0BmgBXAHYAFwApADEAZTwXXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQSvgD2ADIAvgACABAiBBLPTADoAOwAOPBk8IgBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqDwjPCQ8JTwmPCc8KDwpPCqBBLSBBLWBBLaBBLiBBLmBBLqBBLuBBLyAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzwJAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEsggICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc8CQBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBLIICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc8TAAXPAkAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBLeAAIEEsggICAiAHYBDCAiAAAjTADoAOwAOPFo8WwBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFzwJAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEEsggICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc8CQBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBLIICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPAkAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQSyCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzwJAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEEsggICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc8CQBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBLIICAgIgB2ASAgIgAAI2QAhACU8qQAOACg8qgAjAFY8qzldAZsAVwB2ABcAKQAxAGU8s18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEEr4A+gAyAL4AAgAQIgQS+0wA6ADsADjy1PL0ARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpzy+PL88wDzBPMI8wzzEgQS/gQTAgQTBgQTCgQTDgQTEgQTFgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc8CgBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBL0ICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPAoAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQS9CAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzwKAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEEvQgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc8CgBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBL0ICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPAoAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQS9CAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFzwKAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEEvQgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc8CgBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBL0ICAgIgB2AWwgIgAAI3xASAJsAnACdPTAAIQCfAKA9MQAjAJ49MgChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU9OgAxAGUAVwBlAZE5VQBlAGU9QgBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEEyAiADAiAZIEEfAgIgQTHCBLQuBGg0wA6ADsADj1GPUkARqIBmgGbgD2APqI9Sj1LgQTJgQTUgCjZACEAJT1OAA4AKD1PACMAVj1QOV4BmgBXAHYAFwApADEAZT1YXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQTGgD2ADIAvgACABAiBBMrTADoAOwAOPVo9YwBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqD1kPWU9Zj1nPWg9aT1qPWuBBMuBBMyBBM2BBM+BBNCBBNGBBNKBBNOAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz1KAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEEyQgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9SgBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBMkICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc9jQAXPUoAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBM6AAIEEyQgICAiAHYBDCAiAAAjTADoAOwAOPZs9nABGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz1KAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEEyQgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc9SgBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBMkICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPUoAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQTJCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1KAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEEyQgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc9SgBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBMkICAgIgB2ASAgIgAAI2QAhACU96gAOACg96wAjAFY97DleAZsAVwB2ABcAKQAxAGU99F8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEExoA+gAyAL4AAgAQIgQTV0wA6ADsADj32Pf4ARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpz3/PgA+AT4CPgM+BD4FgQTWgQTXgQTYgQTZgQTagQTbgQTcgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9SwBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBNQICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPUsAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQTUCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1LAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEE1AgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc9SwBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBNQICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPUsAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQTUCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz1LAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEE1AgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc9SwBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBNQICAgIgB2AWwgIgAAI3xASAJsAnACdPnEAIQCfAKA+cgAjAJ4+cwChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU+ewAxAGUAVwBlAZE5VgBlAGU+gwBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEE3wiADAiAZIEEfQgIgQTeCBKIjaz00wA6ADsADj6HPooARqIBmgGbgD2APqI+iz6MgQTggQTrgCjZACEAJT6PAA4AKD6QACMAVj6ROV8BmgBXAHYAFwApADEAZT6ZXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQTdgD2ADIAvgACABAiBBOHTADoAOwAOPps+pABGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqD6lPqY+pz6oPqk+qj6rPqyBBOKBBOOBBOSBBOaBBOeBBOiBBOmBBOqAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz6LAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEE4AgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+iwBlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBOAICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABc+zgAXPosAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBOWAAIEE4AgICAiAHYBDCAiAAAjTADoAOwAOPtw+3QBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz6LAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEE4AgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc+iwBlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBOAICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPosAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQTgCAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6LAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEE4AgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc+iwBlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBOAICAgIgB2ASAgIgAAI2QAhACU/KwAOACg/LAAjAFY/LTlfAZsAVwB2ABcAKQAxAGU/NV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEE3YA+gAyAL4AAgAQIgQTs0wA6ADsADj83Pz8ARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbpz9AP0E/Qj9DP0Q/RT9GgQTtgQTugQTvgQTwgQTxgQTygQTzgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+jABlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBOsICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXPowAZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQTrCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6MAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEE6wgICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc+jABlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBOsICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXPowAZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQTrCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz6MAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEE6wgICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc+jABlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBOsICAgIgB2AWwgIgAAI3xASAJsAnACdP7IAIQCfAKA/swAjAJ4/tAChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGU/vAAxAGUAVwBlAZE5VwBlAGU/xABlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEE9giADAiAZIEEfggIgQT1CBK/C3Zn0wA6ADsADj/IP8sARqIBmgGbgD2APqI/zD/NgQT3gQUCgCjZACEAJT/QAA4AKD/RACMAVj/SOWABmgBXAHYAFwApADEAZT/aXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQT0gD2ADIAvgACABAiBBPjTADoAOwAOP9w/5QBGqAGwAbEBsgGzAbQBtQG2AbeAQYBCgEOARIBFgEaAR4BIqD/mP+c/6D/pP+o/6z/sP+2BBPmBBPqBBPuBBP2BBP6BBP+BBQCBBQGAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz/MAGUAZQBlADEAZQCtAbAAZQBlABcAZYAAgCWAAIEE9wgICAiAHYBBCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc/zABlAGUAZQAxAGUArQGxAGUAZQAXAGWAAIAAgACBBPcICAgIgB2AQggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABdADwAXP8wAZQBlAGUAMQBlAK0BsgBlAGUAFwBlgACBBPyAAIEE9wgICAiAHYBDCAiAAAjTADoAOwAOQB1AHgBGoKCAKN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFz/MAGUAZQBlADEAZQCtAbMAZQBlABcAZYAAgCWAAIEE9wgICAiAHYBECAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwIMABc/zABlAGUAZQAxAGUArQG0AGUAZQAXAGWAAIBPgACBBPcICAgIgB2ARQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXP8wAZQBlAGUAMQBlAK0BtQBlAGUAFwBlgACAJYAAgQT3CAgICIAdgEYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz/MAGUAZQBlADEAZQCtAbYAZQBlABcAZYAAgACAAIEE9wgICAiAHYBHCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABc/zABlAGUAZQAxAGUArQG3AGUAZQAXAGWAAIAlgACBBPcICAgIgB2ASAgIgAAI2QAhACVAbAAOAChAbQAjAFZAbjlgAZsAVwB2ABcAKQAxAGVAdl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEE9IA+gAyAL4AAgAQIgQUD0wA6ADsADkB4QIAARqcCVQJWAlcCWAJZAloCW4BVgFaAV4BYgFmAWoBbp0CBQIJAg0CEQIVAhkCHgQUEgQUFgQUGgQUHgQUIgQUJgQUKgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc/zQBlAGUAZQAxAGUArQJVAGUAZQAXAGWAAIAAgACBBQIICAgIgB2AVQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXP80AZQBlAGUAMQBlAK0CVgBlAGUAFwBlgACAJYAAgQUCCAgICIAdgFYICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz/NAGUAZQBlADEAZQCtAlcAZQBlABcAZYAAgACAAIEFAggICAiAHYBXCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwKbABc/zQBlAGUAZQAxAGUArQJYAGUAZQAXAGWAAIBggACBBQIICAgIgB2AWAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXP80AZQBlAGUAMQBlAK0CWQBlAGUAFwBlgACAAIAAgQUCCAgICIAdgFkICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFz/NAGUAZQBlADEAZQCtAloAZQBlABcAZYAAgACAAIEFAggICAiAHYBaCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABc/zQBlAGUAZQAxAGUArQJbAGUAZQAXAGWAAIAAgACBBQIICAgIgB2AWwgIgAAI3xASAJsAnACdQPMAIQCfAKBA9AAjAJ5A9QChAA4AJQCiAKMAKACkABcAFwAXACkARQBlAGVA/QAxAGUAVwBlAZE5WABlAGVBBQBlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASBBGUICIEFDQiADAiAZIEEfwgIgQUMCBMAAAABJXl6eNMAOgA7AA5BCUEMAEaiAZoBm4A9gD6iQQ1BDoEFDoEFGYAo2QAhACVBEQAOAChBEgAjAFZBEzlhAZoAVwB2ABcAKQAxAGVBG18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEFC4A9gAyAL4AAgAQIgQUP0wA6ADsADkEdQSYARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKhBJ0EoQSlBKkErQSxBLUEugQUQgQURgQUSgQUUgQUVgQUWgQUXgQUYgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABdBDQBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBBQ4ICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQQ0AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQUOCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXQVAAF0ENAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQUTgACBBQ4ICAgIgB2AQwgIgAAI0wA6ADsADkFeQV8ARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABdBDQBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBBQ4ICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXQQ0AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQUOCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAF0ENAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEFDggICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdBDQBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBBQ4ICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXQQ0AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQUOCAgICIAdgEgICIAACNkAIQAlQa0ADgAoQa4AIwBWQa85YQGbAFcAdgAXACkAMQBlQbdfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBQuAPoAMgC+AAIAECIEFGtMAOgA7AA5BuUHBAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6dBwkHDQcRBxUHGQcdByIEFG4EFHIEFHYEFHoEFH4EFIIEFIYAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQQ4AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQUZCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAF0EOAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEFGQgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdBDgBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBBRkICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXQQ4AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgQUZCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAF0EOAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEFGQgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdBDgBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBRkICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQQ4AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQUZCAgICIAdgFsICIAACN8QEgCbAJwAnUI0ACEAnwCgQjUAIwCeQjYAoQAOACUAogCjACgApAAXABcAFwApAEUAZQBlQj4AMQBlAFcAZQGROVkAZQBlQkYAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgQRlCAiBBSQIgAwIgGSBBIAICIEFIwgSvsaxe9MAOgA7AA5CSkJNAEaiAZoBm4A9gD6iQk5CT4EFJYEFMIAo2QAhACVCUgAOAChCUwAjAFZCVDliAZoAVwB2ABcAKQAxAGVCXF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYEFIoA9gAyAL4AAgAQIgQUm0wA6ADsADkJeQmcARqgBsAGxAbIBswG0AbUBtgG3gEGAQoBDgESARYBGgEeASKhCaEJpQmpCa0JsQm1CbkJvgQUngQUogQUpgQUrgQUsgQUtgQUugQUvgCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABdCTgBlAGUAZQAxAGUArQGwAGUAZQAXAGWAAIAlgACBBSUICAgIgB2AQQgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQk4AZQBlAGUAMQBlAK0BsQBlAGUAFwBlgACAAIAAgQUlCAgICIAdgEIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXQpEAF0JOAGUAZQBlADEAZQCtAbIAZQBlABcAZYAAgQUqgACBBSUICAgIgB2AQwgIgAAI0wA6ADsADkKfQqAARqCggCjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEHABdCTgBlAGUAZQAxAGUArQGzAGUAZQAXAGWAAIAlgACBBSUICAgIgB2ARAgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCDAAXQk4AZQBlAGUAMQBlAK0BtABlAGUAFwBlgACAT4AAgQUlCAgICIAdgEUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAF0JOAGUAZQBlADEAZQCtAbUAZQBlABcAZYAAgCWAAIEFJQgICAiAHYBGCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdCTgBlAGUAZQAxAGUArQG2AGUAZQAXAGWAAIAAgACBBSUICAgIgB2ARwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcBBwAXQk4AZQBlAGUAMQBlAK0BtwBlAGUAFwBlgACAJYAAgQUlCAgICIAdgEgICIAACNkAIQAlQu4ADgAoQu8AIwBWQvA5YgGbAFcAdgAXACkAMQBlQvhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBSKAPoAMgC+AAIAECIEFMdMAOgA7AA5C+kMCAEanAlUCVgJXAlgCWQJaAluAVYBWgFeAWIBZgFqAW6dDA0MEQwVDBkMHQwhDCYEFMoEFM4EFNIEFNYEFNoEFN4EFOIAo3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQk8AZQBlAGUAMQBlAK0CVQBlAGUAFwBlgACAAIAAgQUwCAgICIAdgFUICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAF0JPAGUAZQBlADEAZQCtAlYAZQBlABcAZYAAgCWAAIEFMAgICAiAHYBWCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdCTwBlAGUAZQAxAGUArQJXAGUAZQAXAGWAAIAAgACBBTAICAgIgB2AVwgIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcCmwAXQk8AZQBlAGUAMQBlAK0CWABlAGUAFwBlgACAYIAAgQUwCAgICIAdgFgICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAF0JPAGUAZQBlADEAZQCtAlkAZQBlABcAZYAAgACAAIEFMAgICAiAHYBZCAiAAAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwAXABdCTwBlAGUAZQAxAGUArQJaAGUAZQAXAGWAAIAAgACBBTAICAgIgB2AWggIgAAI3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcAFwAXQk8AZQBlAGUAMQBlAK0CWwBlAGUAFwBlgACAAIAAgQUwCAgICIAdgFsICIAACNIAOwAOQ3UAtaCAHNMAOgA7AA5DeEN5AEagoIAo0wA6ADsADkN8Q30ARqCggCjTADoAOwAOQ4BDgQBGoKCAKNIAtwC4Q4RDhV5YRE1vZGVsUGFja2FnZaZDhkOHQ4hDiUOKALxeWERNb2RlbFBhY2thZ2VfEA9YRFVNTFBhY2thZ2VJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w0gA7AA5DjAC1oIAc0wA6ADsADkOPQ5AARqCggCjSALcAuEOTQ5RZWERQTU1vZGVso0OTQ5UAvFdYRE1vZGVsAAAACAAAABkAAAAiAAAALAAAADEAAAA6AAAAPwAAAFEAAABWAAAAWwAAAF0AAArjAAAK6QAACwYAAAsYAAALHwAACywAAAs/AAALVwAAC2UAAAt/AAALgQAAC4QAAAuHAAALiQAAC4wAAAuOAAALkQAAC8oAAAvpAAAMBgAADCUAAAw3AAAMVwAADF4AAAx8AAAMiAAADKQAAAyqAAAMzAAADO0AAA0AAAANAgAADQUAAA0IAAANCgAADQwAAA0OAAANEQAADRQAAA0WAAANGAAADRoAAA0cAAANHgAADSAAAA0hAAANJQAADTIAAA06AAANRQAADU4AAA1QAAANUgAADVQAAA1WAAANXwAADWEAAA1kAAANZwAADWoAAA1sAAANewAADY0AAA2XAAANoAAADeMAAA4HAAAOKwAADk4AAA51AAAOlQAADrwAAA7jAAAPAwAADycAAA9LAAAPVwAAD1kAAA9bAAAPXQAAD18AAA9hAAAPYwAAD2YAAA9oAAAPagAAD20AAA9vAAAPcQAAD3QAAA92AAAPdwAAD3wAAA+EAAAPkQAAD5QAAA+WAAAPmQAAD5sAAA+dAAAPrAAAD9EAAA/1AAAQHAAAEEAAABBCAAAQRAAAEEYAABBIAAAQSgAAEEwAABBNAAAQTwAAEFwAABBvAAAQcQAAEHMAABB1AAAQdwAAEHkAABB7AAAQfQAAEH8AABCBAAAQlAAAEJYAABCYAAAQmgAAEJwAABCeAAAQoAAAEKIAABCkAAAQpgAAEKgAABC+AAAQ0QAAEO0AABEKAAARJgAAEToAABFMAAARYgAAEXsAABG6AAARwAAAEckAABHWAAAR4gAAEewAABH2AAASAQAAEgwAABIZAAASIQAAEiMAABIlAAASJwAAEikAABIqAAASKwAAEiwAABItAAASLwAAEjEAABIyAAASMwAAEjUAABI2AAASPwAAEkAAABJCAAASSwAAElYAABJfAAASbgAAEnUAABJ9AAAShgAAEo8AABKiAAASqwAAEr4AABLVAAAS5wAAEyYAABMoAAATKgAAEywAABMuAAATLwAAEzAAABMxAAATMgAAEzQAABM2AAATNwAAEzgAABM6AAATOwAAE3oAABN8AAATfgAAE4AAABOCAAATgwAAE4QAABOFAAAThgAAE4gAABOKAAATiwAAE4wAABOOAAATjwAAE5gAABOZAAATmwAAE9oAABPcAAAT3gAAE+AAABPiAAAT4wAAE+QAABPlAAAT5gAAE+gAABPqAAAT6wAAE+wAABPuAAAT7wAAFC4AABQwAAAUMgAAFDQAABQ2AAAUNwAAFDgAABQ5AAAUOgAAFDwAABQ+AAAUPwAAFEAAABRCAAAUQwAAFEwAABRNAAAUTwAAFI4AABSQAAAUkgAAFJQAABSWAAAUlwAAFJgAABSZAAAUmgAAFJwAABSeAAAUnwAAFKAAABSiAAAUowAAFKQAABTjAAAU5QAAFOcAABTpAAAU6wAAFOwAABTtAAAU7gAAFO8AABTxAAAU8wAAFPQAABT1AAAU9wAAFPgAABUFAAAVBgAAFQcAABUJAAAVEgAAFSgAABUvAAAVPAAAFXsAABV9AAAVfwAAFYEAABWDAAAVhAAAFYUAABWGAAAVhwAAFYkAABWLAAAVjAAAFY0AABWPAAAVkAAAFakAABWrAAAVrQAAFa8AABWwAAAVsgAAFckAABXSAAAV4AAAFe0AABX7AAAWEAAAFiQAABY7AAAWTQAAFowAABaOAAAWkAAAFpIAABaUAAAWlQAAFpYAABaXAAAWmAAAFpoAABacAAAWnQAAFp4AABagAAAWoQAAFrMAABa8AAAW0QAAFuAAABb1AAAXAwAAFxgAABcsAAAXQwAAF1UAABdiAAAXdQAAF3cAABd5AAAXewAAF30AABd/AAAXgQAAF4MAABeFAAAXhwAAF5oAABecAAAXngAAF6AAABeiAAAXpAAAF6YAABeoAAAXqgAAF60AABevAAAXuAAAF70AABfPAAAX3QAAF+cAABf7AAAYCQAAGA0AABgZAAAYZAAAGIcAABinAAAYxwAAGMkAABjLAAAYzQAAGM8AABjRAAAY0gAAGNMAABjVAAAY1gAAGNgAABjZAAAY2wAAGN0AABjeAAAY3wAAGOEAABjiAAAY6wAAGPgAABj9AAAY/wAAGQEAABkGAAAZCAAAGQoAABkMAAAZIQAAGTYAABlbAAAZfwAAGaYAABnKAAAZzAAAGc4AABnQAAAZ0gAAGdQAABnWAAAZ1wAAGdkAABnmAAAZ9wAAGfkAABn7AAAZ/QAAGf8AABoBAAAaAwAAGgUAABoHAAAaGAAAGhoAABocAAAaHgAAGiAAABoiAAAaJAAAGiYAABooAAAaKgAAGkgAABpmAAAaeQAAGo0AABqiAAAavwAAGtMAABrpAAAbKAAAGyoAABssAAAbLgAAGzAAABsxAAAbMgAAGzMAABs0AAAbNgAAGzgAABs5AAAbOgAAGzwAABs9AAAbfAAAG34AABuAAAAbggAAG4QAABuFAAAbhgAAG4cAABuIAAAbigAAG4wAABuNAAAbjgAAG5AAABuRAAAb0AAAG9IAABvUAAAb1gAAG9gAABvZAAAb2gAAG9sAABvcAAAb3gAAG+AAABvhAAAb4gAAG+QAABvlAAAb8gAAG/MAABv0AAAb9gAAHDUAABw3AAAcOQAAHDsAABw9AAAcPgAAHD8AABxAAAAcQQAAHEMAABxFAAAcRgAAHEcAABxJAAAcSgAAHIkAAByLAAAcjQAAHI8AAByRAAAckgAAHJMAAByUAAAclQAAHJcAAByZAAAcmgAAHJsAABydAAAcngAAHJ8AABzeAAAc4AAAHOIAABzkAAAc5gAAHOcAABzoAAAc6QAAHOoAABzsAAAc7gAAHO8AABzwAAAc8gAAHPMAAB0yAAAdNAAAHTYAAB04AAAdOgAAHTsAAB08AAAdPQAAHT4AAB1AAAAdQgAAHUMAAB1EAAAdRgAAHUcAAB2GAAAdiAAAHYoAAB2MAAAdjgAAHY8AAB2QAAAdkQAAHZIAAB2UAAAdlgAAHZcAAB2YAAAdmgAAHZsAAB3AAAAd5AAAHgsAAB4vAAAeMQAAHjMAAB41AAAeNwAAHjkAAB47AAAePAAAHj4AAB5LAAAeWgAAHlwAAB5eAAAeYAAAHmIAAB5kAAAeZgAAHmgAAB53AAAeeQAAHnsAAB59AAAefwAAHoEAAB6DAAAehQAAHocAAB6nAAAe0gAAHuwAAB8FAAAfHwAAHz8AAB9iAAAfoQAAH6MAAB+lAAAfpwAAH6kAAB+qAAAfqwAAH6wAAB+tAAAfrwAAH7EAAB+yAAAfswAAH7UAAB+2AAAf9QAAH/cAAB/5AAAf+wAAH/0AAB/+AAAf/wAAIAAAACABAAAgAwAAIAUAACAGAAAgBwAAIAkAACAKAAAgSQAAIEsAACBNAAAgTwAAIFEAACBSAAAgUwAAIFQAACBVAAAgVwAAIFkAACBaAAAgWwAAIF0AACBeAAAgnQAAIJ8AACChAAAgowAAIKUAACCmAAAgpwAAIKgAACCpAAAgqwAAIK0AACCuAAAgrwAAILEAACCyAAAgtQAAIPQAACD2AAAg+AAAIPoAACD8AAAg/QAAIP4AACD/AAAhAAAAIQIAACEEAAAhBQAAIQYAACEIAAAhCQAAIUgAACFKAAAhTAAAIU4AACFQAAAhUQAAIVIAACFTAAAhVAAAIVYAACFYAAAhWQAAIVoAACFcAAAhXQAAIZwAACGeAAAhoAAAIaIAACGkAAAhpQAAIaYAACGnAAAhqAAAIaoAACGsAAAhrQAAIa4AACGwAAAhsQAAIboAACHIAAAh1QAAIeMAACHwAAAiAwAAIhoAACIsAAAidwAAIpoAACK6AAAi2gAAItwAACLeAAAi4AAAIuIAACLkAAAi5QAAIuYAACLoAAAi6QAAIusAACLsAAAi7gAAIvAAACLxAAAi8gAAIvQAACL1AAAi/gAAIwsAACMQAAAjEgAAIxQAACMZAAAjGwAAIx0AACMfAAAjRAAAI2gAACOPAAAjswAAI7UAACO3AAAjuQAAI7sAACO9AAAjvwAAI8AAACPCAAAjzwAAI+AAACPiAAAj5AAAI+YAACPoAAAj6gAAI+wAACPuAAAj8AAAJAEAACQDAAAkBQAAJAcAACQJAAAkCwAAJA0AACQPAAAkEQAAJBMAACRSAAAkVAAAJFYAACRYAAAkWgAAJFsAACRcAAAkXQAAJF4AACRgAAAkYgAAJGMAACRkAAAkZgAAJGcAACSmAAAkqAAAJKoAACSsAAAkrgAAJK8AACSwAAAksQAAJLIAACS0AAAktgAAJLcAACS4AAAkugAAJLsAACT6AAAk/AAAJP4AACUAAAAlAgAAJQMAACUEAAAlBQAAJQYAACUIAAAlCgAAJQsAACUMAAAlDgAAJQ8AACUcAAAlHQAAJR4AACUgAAAlXwAAJWEAACVjAAAlZQAAJWcAACVoAAAlaQAAJWoAACVrAAAlbQAAJW8AACVwAAAlcQAAJXMAACV0AAAlswAAJbUAACW3AAAluQAAJbsAACW8AAAlvQAAJb4AACW/AAAlwQAAJcMAACXEAAAlxQAAJccAACXIAAAmBwAAJgkAACYLAAAmDQAAJg8AACYQAAAmEQAAJhIAACYTAAAmFQAAJhcAACYYAAAmGQAAJhsAACYcAAAmWwAAJl0AACZfAAAmYQAAJmMAACZkAAAmZQAAJmYAACZnAAAmaQAAJmsAACZsAAAmbQAAJm8AACZwAAAmrwAAJrEAACazAAAmtQAAJrcAACa4AAAmuQAAJroAACa7AAAmvQAAJr8AACbAAAAmwQAAJsMAACbEAAAm6QAAJw0AACc0AAAnWAAAJ1oAACdcAAAnXgAAJ2AAACdiAAAnZAAAJ2UAACdnAAAndAAAJ4MAACeFAAAnhwAAJ4kAACeLAAAnjQAAJ48AACeRAAAnoAAAJ6IAACekAAAnpgAAJ6gAACeqAAAnrAAAJ64AACewAAAn7wAAJ/EAACfzAAAn9QAAJ/cAACf4AAAn+QAAJ/oAACf7AAAn/QAAJ/8AACgAAAAoAQAAKAMAACgEAAAoQwAAKEUAAChHAAAoSQAAKEsAAChMAAAoTQAAKE4AAChPAAAoUQAAKFMAAChUAAAoVQAAKFcAAChYAAAolwAAKJkAACibAAAonQAAKJ8AACigAAAooQAAKKIAACijAAAopQAAKKcAACioAAAoqQAAKKsAACisAAAo6wAAKO0AACjvAAAo8QAAKPMAACj0AAAo9QAAKPYAACj3AAAo+QAAKPsAACj8AAAo/QAAKP8AACkAAAApPwAAKUEAAClDAAApRQAAKUcAAClIAAApSQAAKUoAAClLAAApTQAAKU8AAClQAAApUQAAKVMAAClUAAApkwAAKZUAACmXAAApmQAAKZsAACmcAAApnQAAKZ4AACmfAAApoQAAKaMAACmkAAAppQAAKacAACmoAAAp5wAAKekAACnrAAAp7QAAKe8AACnwAAAp8QAAKfIAACnzAAAp9QAAKfcAACn4AAAp+QAAKfsAACn8AAAqRwAAKmoAACqKAAAqqgAAKqwAACquAAAqsAAAKrIAACq0AAAqtQAAKrYAACq4AAAquQAAKrsAACq8AAAqvgAAKsAAACrBAAAqwgAAKsQAACrFAAAqygAAKtcAACrcAAAq3gAAKuAAACrlAAAq5wAAKukAACrrAAArEAAAKzQAACtbAAArfwAAK4EAACuDAAArhQAAK4cAACuJAAAriwAAK4wAACuOAAArmwAAK6wAACuuAAArsAAAK7IAACu0AAArtgAAK7gAACu6AAArvAAAK80AACvPAAAr0QAAK9MAACvVAAAr1wAAK9kAACvbAAAr3QAAK98AACweAAAsIAAALCIAACwkAAAsJgAALCcAACwoAAAsKQAALCoAACwsAAAsLgAALC8AACwwAAAsMgAALDMAACxyAAAsdAAALHYAACx4AAAsegAALHsAACx8AAAsfQAALH4AACyAAAAsggAALIMAACyEAAAshgAALIcAACzGAAAsyAAALMoAACzMAAAszgAALM8AACzQAAAs0QAALNIAACzUAAAs1gAALNcAACzYAAAs2gAALNsAACzoAAAs6QAALOoAACzsAAAtKwAALS0AAC0vAAAtMQAALTMAAC00AAAtNQAALTYAAC03AAAtOQAALTsAAC08AAAtPQAALT8AAC1AAAAtfwAALYEAAC2DAAAthQAALYcAAC2IAAAtiQAALYoAAC2LAAAtjQAALY8AAC2QAAAtkQAALZMAAC2UAAAt0wAALdUAAC3XAAAt2QAALdsAAC3cAAAt3QAALd4AAC3fAAAt4QAALeMAAC3kAAAt5QAALecAAC3oAAAuJwAALikAAC4rAAAuLQAALi8AAC4wAAAuMQAALjIAAC4zAAAuNQAALjcAAC44AAAuOQAALjsAAC48AAAuewAALn0AAC5/AAAugQAALoMAAC6EAAAuhQAALoYAAC6HAAAuiQAALosAAC6MAAAujQAALo8AAC6QAAAutQAALtkAAC8AAAAvJAAALyYAAC8oAAAvKgAALywAAC8uAAAvMAAALzEAAC8zAAAvQAAAL08AAC9RAAAvUwAAL1UAAC9XAAAvWQAAL1sAAC9dAAAvbAAAL24AAC9wAAAvcgAAL3QAAC92AAAveAAAL3oAAC98AAAvuwAAL70AAC+/AAAvwQAAL8MAAC/EAAAvxQAAL8YAAC/HAAAvyQAAL8sAAC/MAAAvzQAAL88AAC/QAAAv0gAAMBEAADATAAAwFQAAMBcAADAZAAAwGgAAMBsAADAcAAAwHQAAMB8AADAhAAAwIgAAMCMAADAlAAAwJgAAMGUAADBnAAAwaQAAMGsAADBtAAAwbgAAMG8AADBwAAAwcQAAMHMAADB1AAAwdgAAMHcAADB5AAAwegAAMLkAADC7AAAwvQAAML8AADDBAAAwwgAAMMMAADDEAAAwxQAAMMcAADDJAAAwygAAMMsAADDNAAAwzgAAMNEAADEQAAAxEgAAMRQAADEWAAAxGAAAMRkAADEaAAAxGwAAMRwAADEeAAAxIAAAMSEAADEiAAAxJAAAMSUAADFkAAAxZgAAMWgAADFqAAAxbAAAMW0AADFuAAAxbwAAMXAAADFyAAAxdAAAMXUAADF2AAAxeAAAMXkAADG4AAAxugAAMbwAADG+AAAxwAAAMcEAADHCAAAxwwAAMcQAADHGAAAxyAAAMckAADHKAAAxzAAAMc0AADIYAAAyOwAAMlsAADJ7AAAyfQAAMn8AADKBAAAygwAAMoUAADKGAAAyhwAAMokAADKKAAAyjAAAMo0AADKPAAAykQAAMpIAADKTAAAylQAAMpYAADKbAAAyqAAAMq0AADKvAAAysQAAMrYAADK4AAAyugAAMrwAADLhAAAzBQAAMywAADNQAAAzUgAAM1QAADNWAAAzWAAAM1oAADNcAAAzXQAAM18AADNsAAAzfQAAM38AADOBAAAzgwAAM4UAADOHAAAziQAAM4sAADONAAAzngAAM6AAADOiAAAzpAAAM6YAADOoAAAzqgAAM6wAADOuAAAzsAAAM+8AADPxAAAz8wAAM/UAADP3AAAz+AAAM/kAADP6AAAz+wAAM/0AADP/AAA0AAAANAEAADQDAAA0BAAANEMAADRFAAA0RwAANEkAADRLAAA0TAAANE0AADROAAA0TwAANFEAADRTAAA0VAAANFUAADRXAAA0WAAANJcAADSZAAA0mwAANJ0AADSfAAA0oAAANKEAADSiAAA0owAANKUAADSnAAA0qAAANKkAADSrAAA0rAAANLkAADS6AAA0uwAANL0AADT8AAA0/gAANQAAADUCAAA1BAAANQUAADUGAAA1BwAANQgAADUKAAA1DAAANQ0AADUOAAA1EAAANREAADVQAAA1UgAANVQAADVWAAA1WAAANVkAADVaAAA1WwAANVwAADVeAAA1YAAANWEAADViAAA1ZAAANWUAADWkAAA1pgAANagAADWqAAA1rAAANa0AADWuAAA1rwAANbAAADWyAAA1tAAANbUAADW2AAA1uAAANbkAADX4AAA1+gAANfwAADX+AAA2AAAANgEAADYCAAA2AwAANgQAADYGAAA2CAAANgkAADYKAAA2DAAANg0AADZMAAA2TgAANlAAADZSAAA2VAAANlUAADZWAAA2VwAANlgAADZaAAA2XAAANl0AADZeAAA2YAAANmEAADaGAAA2qgAANtEAADb1AAA29wAANvkAADb7AAA2/QAANv8AADcBAAA3AgAANwQAADcRAAA3IAAANyIAADckAAA3JgAANygAADcqAAA3LAAANy4AADc9AAA3PwAAN0EAADdDAAA3RQAAN0cAADdJAAA3SwAAN00AADeMAAA3jgAAN5AAADeSAAA3lAAAN5UAADeWAAA3lwAAN5gAADeaAAA3nAAAN50AADeeAAA3oAAAN6EAADfgAAA34gAAN+QAADfmAAA36AAAN+kAADfqAAA36wAAN+wAADfuAAA38AAAN/EAADfyAAA39AAAN/UAADg0AAA4NgAAODgAADg6AAA4PAAAOD0AADg+AAA4PwAAOEAAADhCAAA4RAAAOEUAADhGAAA4SAAAOEkAADiIAAA4igAAOIwAADiOAAA4kAAAOJEAADiSAAA4kwAAOJQAADiWAAA4mAAAOJkAADiaAAA4nAAAOJ0AADjcAAA43gAAOOAAADjiAAA45AAAOOUAADjmAAA45wAAOOgAADjqAAA47AAAOO0AADjuAAA48AAAOPEAADkwAAA5MgAAOTQAADk2AAA5OAAAOTkAADk6AAA5OwAAOTwAADk+AAA5QAAAOUEAADlCAAA5RAAAOUUAADmEAAA5hgAAOYgAADmKAAA5jAAAOY0AADmOAAA5jwAAOZAAADmSAAA5lAAAOZUAADmWAAA5mAAAOZkAADnkAAA6BwAAOicAADpHAAA6SQAAOksAADpNAAA6TwAAOlEAADpSAAA6UwAAOlUAADpWAAA6WAAAOlkAADpbAAA6XQAAOl4AADpfAAA6YQAAOmIAADpnAAA6dAAAOnkAADp7AAA6fQAAOoIAADqEAAA6hgAAOogAADqtAAA60QAAOvgAADscAAA7HgAAOyAAADsiAAA7JAAAOyYAADsoAAA7KQAAOysAADs4AAA7SQAAO0sAADtNAAA7TwAAO1EAADtTAAA7VQAAO1cAADtZAAA7agAAO2wAADtuAAA7cAAAO3IAADt0AAA7dgAAO3gAADt6AAA7fAAAO7sAADu9AAA7vwAAO8EAADvDAAA7xAAAO8UAADvGAAA7xwAAO8kAADvLAAA7zAAAO80AADvPAAA70AAAPA8AADwRAAA8EwAAPBUAADwXAAA8GAAAPBkAADwaAAA8GwAAPB0AADwfAAA8IAAAPCEAADwjAAA8JAAAPGMAADxlAAA8ZwAAPGkAADxrAAA8bAAAPG0AADxuAAA8bwAAPHEAADxzAAA8dAAAPHUAADx3AAA8eAAAPIUAADyGAAA8hwAAPIkAADzIAAA8ygAAPMwAADzOAAA80AAAPNEAADzSAAA80wAAPNQAADzWAAA82AAAPNkAADzaAAA83AAAPN0AAD0cAAA9HgAAPSAAAD0iAAA9JAAAPSUAAD0mAAA9JwAAPSgAAD0qAAA9LAAAPS0AAD0uAAA9MAAAPTEAAD1wAAA9cgAAPXQAAD12AAA9eAAAPXkAAD16AAA9ewAAPXwAAD1+AAA9gAAAPYEAAD2CAAA9hAAAPYUAAD3EAAA9xgAAPcgAAD3KAAA9zAAAPc0AAD3OAAA9zwAAPdAAAD3SAAA91AAAPdUAAD3WAAA92AAAPdkAAD4YAAA+GgAAPhwAAD4eAAA+IAAAPiEAAD4iAAA+IwAAPiQAAD4mAAA+KAAAPikAAD4qAAA+LAAAPi0AAD5SAAA+dgAAPp0AAD7BAAA+wwAAPsUAAD7HAAA+yQAAPssAAD7NAAA+zgAAPtAAAD7dAAA+7AAAPu4AAD7wAAA+8gAAPvQAAD72AAA++AAAPvoAAD8JAAA/CwAAPw0AAD8PAAA/EQAAPxMAAD8VAAA/FwAAPxkAAD9YAAA/WgAAP1wAAD9eAAA/YAAAP2EAAD9iAAA/YwAAP2QAAD9mAAA/aAAAP2kAAD9qAAA/bAAAP20AAD+sAAA/rgAAP7AAAD+yAAA/tAAAP7UAAD+2AAA/twAAP7gAAD+6AAA/vAAAP70AAD++AAA/wAAAP8EAAEAAAABAAgAAQAQAAEAGAABACAAAQAkAAEAKAABACwAAQAwAAEAOAABAEAAAQBEAAEASAABAFAAAQBUAAEBUAABAVgAAQFgAAEBaAABAXAAAQF0AAEBeAABAXwAAQGAAAEBiAABAZAAAQGUAAEBmAABAaAAAQGkAAECoAABAqgAAQKwAAECuAABAsAAAQLEAAECyAABAswAAQLQAAEC2AABAuAAAQLkAAEC6AABAvAAAQL0AAED8AABA/gAAQQAAAEECAABBBAAAQQUAAEEGAABBBwAAQQgAAEEKAABBDAAAQQ0AAEEOAABBEAAAQREAAEFQAABBUgAAQVQAAEFWAABBWAAAQVkAAEFaAABBWwAAQVwAAEFeAABBYAAAQWEAAEFiAABBZAAAQWUAAEGwAABB0wAAQfMAAEITAABCFQAAQhcAAEIZAABCGwAAQh0AAEIeAABCHwAAQiEAAEIiAABCJAAAQiUAAEInAABCKQAAQioAAEIrAABCLQAAQi4AAEIzAABCQAAAQkUAAEJHAABCSQAAQk4AAEJQAABCUgAAQlQAAEJ5AABCnQAAQsQAAELoAABC6gAAQuwAAELuAABC8AAAQvIAAEL0AABC9QAAQvcAAEMEAABDFQAAQxcAAEMZAABDGwAAQx0AAEMfAABDIQAAQyMAAEMlAABDNgAAQzgAAEM6AABDPAAAQz4AAENAAABDQgAAQ0QAAENGAABDSAAAQ4cAAEOJAABDiwAAQ40AAEOPAABDkAAAQ5EAAEOSAABDkwAAQ5UAAEOXAABDmAAAQ5kAAEObAABDnAAAQ9sAAEPdAABD3wAAQ+EAAEPjAABD5AAAQ+UAAEPmAABD5wAAQ+kAAEPrAABD7AAAQ+0AAEPvAABD8AAARC8AAEQxAABEMwAARDUAAEQ3AABEOAAARDkAAEQ6AABEOwAARD0AAEQ/AABEQAAAREEAAERDAABERAAARFEAAERSAABEUwAARFUAAESUAABElgAARJgAAESaAABEnAAARJ0AAESeAABEnwAARKAAAESiAABEpAAARKUAAESmAABEqAAARKkAAEToAABE6gAAROwAAETuAABE8AAARPEAAETyAABE8wAARPQAAET2AABE+AAARPkAAET6AABE/AAARP0AAEU8AABFPgAARUAAAEVCAABFRAAARUUAAEVGAABFRwAARUgAAEVKAABFTAAARU0AAEVOAABFUAAARVEAAEWQAABFkgAARZQAAEWWAABFmAAARZkAAEWaAABFmwAARZwAAEWeAABFoAAARaEAAEWiAABFpAAARaUAAEXkAABF5gAARegAAEXqAABF7AAARe0AAEXuAABF7wAARfAAAEXyAABF9AAARfUAAEX2AABF+AAARfkAAEYeAABGQgAARmkAAEaNAABGjwAARpEAAEaTAABGlQAARpcAAEaZAABGmgAARpwAAEapAABGuAAARroAAEa8AABGvgAARsAAAEbCAABGxAAARsYAAEbVAABG1wAARtkAAEbbAABG3QAARt8AAEbhAABG4wAARuUAAEckAABHJgAARygAAEcqAABHLAAARy0AAEcuAABHLwAARzAAAEcyAABHNAAARzUAAEc2AABHOAAARzkAAEd4AABHegAAR3wAAEd+AABHgAAAR4EAAEeCAABHgwAAR4QAAEeGAABHiAAAR4kAAEeKAABHjAAAR40AAEfMAABHzgAAR9AAAEfSAABH1AAAR9UAAEfWAABH1wAAR9gAAEfaAABH3AAAR90AAEfeAABH4AAAR+EAAEggAABIIgAASCQAAEgmAABIKAAASCkAAEgqAABIKwAASCwAAEguAABIMAAASDEAAEgyAABINAAASDUAAEh0AABIdgAASHgAAEh6AABIfAAASH0AAEh+AABIfwAASIAAAEiCAABIhAAASIUAAEiGAABIiAAASIkAAEjIAABIygAASMwAAEjOAABI0AAASNEAAEjSAABI0wAASNQAAEjWAABI2AAASNkAAEjaAABI3AAASN0AAEkcAABJHgAASSAAAEkiAABJJAAASSUAAEkmAABJJwAASSgAAEkqAABJLAAASS0AAEkuAABJMAAASTEAAEl8AABJnwAASb8AAEnfAABJ4QAASeMAAEnlAABJ5wAASekAAEnqAABJ6wAASe0AAEnuAABJ8AAASfEAAEnzAABJ9QAASfYAAEn3AABJ+QAASfoAAEn/AABKDAAAShEAAEoTAABKFQAAShoAAEocAABKHgAASiAAAEpFAABKaQAASpAAAEq0AABKtgAASrgAAEq6AABKvAAASr4AAErAAABKwQAASsMAAErQAABK4QAASuMAAErlAABK5wAASukAAErrAABK7QAASu8AAErxAABLAgAASwQAAEsGAABLCAAASwoAAEsMAABLDgAASxAAAEsSAABLFAAAS1MAAEtVAABLVwAAS1kAAEtbAABLXAAAS10AAEteAABLXwAAS2EAAEtjAABLZAAAS2UAAEtnAABLaAAAS6cAAEupAABLqwAAS60AAEuvAABLsAAAS7EAAEuyAABLswAAS7UAAEu3AABLuAAAS7kAAEu7AABLvAAAS/sAAEv9AABL/wAATAEAAEwDAABMBAAATAUAAEwGAABMBwAATAkAAEwLAABMDAAATA0AAEwPAABMEAAATB0AAEweAABMHwAATCEAAExgAABMYgAATGQAAExmAABMaAAATGkAAExqAABMawAATGwAAExuAABMcAAATHEAAExyAABMdAAATHUAAEy0AABMtgAATLgAAEy6AABMvAAATL0AAEy+AABMvwAATMAAAEzCAABMxAAATMUAAEzGAABMyAAATMkAAE0IAABNCgAATQwAAE0OAABNEAAATREAAE0SAABNEwAATRQAAE0WAABNGAAATRkAAE0aAABNHAAATR0AAE1cAABNXgAATWAAAE1iAABNZAAATWUAAE1mAABNZwAATWgAAE1qAABNbAAATW0AAE1uAABNcAAATXEAAE2wAABNsgAATbQAAE22AABNuAAATbkAAE26AABNuwAATbwAAE2+AABNwAAATcEAAE3CAABNxAAATcUAAE3qAABODgAATjUAAE5ZAABOWwAATl0AAE5fAABOYQAATmMAAE5lAABOZgAATmgAAE51AABOhAAAToYAAE6IAABOigAATowAAE6OAABOkAAATpIAAE6hAABOowAATqUAAE6nAABOqQAATqsAAE6tAABOrwAATrEAAE7wAABO8gAATvQAAE72AABO+AAATvkAAE76AABO+wAATvwAAE7+AABPAAAATwEAAE8CAABPBAAATwUAAE9EAABPRgAAT0gAAE9KAABPTAAAT00AAE9OAABPTwAAT1AAAE9SAABPVAAAT1UAAE9WAABPWAAAT1kAAE+YAABPmgAAT5wAAE+eAABPoAAAT6EAAE+iAABPowAAT6QAAE+mAABPqAAAT6kAAE+qAABPrAAAT60AAE/sAABP7gAAT/AAAE/yAABP9AAAT/UAAE/2AABP9wAAT/gAAE/6AABP/AAAT/0AAE/+AABQAAAAUAEAAFBAAABQQgAAUEQAAFBGAABQSAAAUEkAAFBKAABQSwAAUEwAAFBOAABQUAAAUFEAAFBSAABQVAAAUFUAAFCUAABQlgAAUJgAAFCaAABQnAAAUJ0AAFCeAABQnwAAUKAAAFCiAABQpAAAUKUAAFCmAABQqAAAUKkAAFDoAABQ6gAAUOwAAFDuAABQ8AAAUPEAAFDyAABQ8wAAUPQAAFD2AABQ+AAAUPkAAFD6AABQ/AAAUP0AAFFIAABRawAAUYsAAFGrAABRrQAAUa8AAFGxAABRswAAUbUAAFG2AABRtwAAUbkAAFG6AABRvAAAUb0AAFG/AABRwQAAUcIAAFHDAABRxQAAUcYAAFHLAABR2AAAUd0AAFHfAABR4QAAUeYAAFHoAABR6gAAUewAAFIRAABSNQAAUlwAAFKAAABSggAAUoQAAFKGAABSiAAAUooAAFKMAABSjQAAUo8AAFKcAABSrQAAUq8AAFKxAABSswAAUrUAAFK3AABSuQAAUrsAAFK9AABSzgAAUtAAAFLSAABS1AAAUtYAAFLYAABS2gAAUtwAAFLeAABS4AAAUx8AAFMhAABTIwAAUyUAAFMnAABTKAAAUykAAFMqAABTKwAAUy0AAFMvAABTMAAAUzEAAFMzAABTNAAAU3MAAFN1AABTdwAAU3kAAFN7AABTfAAAU30AAFN+AABTfwAAU4EAAFODAABThAAAU4UAAFOHAABTiAAAU8cAAFPJAABTywAAU80AAFPPAABT0AAAU9EAAFPSAABT0wAAU9UAAFPXAABT2AAAU9kAAFPbAABT3AAAU+kAAFPqAABT6wAAU+0AAFQsAABULgAAVDAAAFQyAABUNAAAVDUAAFQ2AABUNwAAVDgAAFQ6AABUPAAAVD0AAFQ+AABUQAAAVEEAAFSAAABUggAAVIQAAFSGAABUiAAAVIkAAFSKAABUiwAAVIwAAFSOAABUkAAAVJEAAFSSAABUlAAAVJUAAFTUAABU1gAAVNgAAFTaAABU3AAAVN0AAFTeAABU3wAAVOAAAFTiAABU5AAAVOUAAFTmAABU6AAAVOkAAFUoAABVKgAAVSwAAFUuAABVMAAAVTEAAFUyAABVMwAAVTQAAFU2AABVOAAAVTkAAFU6AABVPAAAVT0AAFV8AABVfgAAVYAAAFWCAABVhAAAVYUAAFWGAABVhwAAVYgAAFWKAABVjAAAVY0AAFWOAABVkAAAVZEAAFW2AABV2gAAVgEAAFYlAABWJwAAVikAAFYrAABWLQAAVi8AAFYxAABWMgAAVjUAAFZCAABWUQAAVlMAAFZVAABWVwAAVlkAAFZbAABWXQAAVl8AAFZuAABWcQAAVnQAAFZ3AABWegAAVn0AAFaAAABWgwAAVoUAAFbEAABWxgAAVskAAFbLAABWzQAAVs4AAFbPAABW0AAAVtEAAFbTAABW1QAAVtYAAFbXAABW2QAAVtoAAFbbAABXGgAAVxwAAFceAABXIAAAVyIAAFcjAABXJAAAVyUAAFcmAABXKAAAVyoAAFcrAABXLAAAVy4AAFcvAABXbgAAV3AAAFdyAABXdAAAV3YAAFd3AABXeAAAV3kAAFd6AABXfAAAV34AAFd/AABXgAAAV4IAAFeDAABXwgAAV8QAAFfHAABXyQAAV8sAAFfMAABXzQAAV84AAFfPAABX0QAAV9MAAFfUAABX1QAAV9cAAFfYAABX2wAAWBoAAFgcAABYHgAAWCAAAFgiAABYIwAAWCQAAFglAABYJgAAWCgAAFgqAABYKwAAWCwAAFguAABYLwAAWG4AAFhwAABYcgAAWHQAAFh2AABYdwAAWHgAAFh5AABYegAAWHwAAFh+AABYfwAAWIAAAFiCAABYgwAAWMIAAFjEAABYxgAAWMgAAFjKAABYywAAWMwAAFjNAABYzgAAWNAAAFjSAABY0wAAWNQAAFjWAABY1wAAWSIAAFlFAABZZQAAWYUAAFmHAABZiQAAWYsAAFmNAABZjwAAWZAAAFmRAABZlAAAWZUAAFmXAABZmAAAWZoAAFmcAABZnQAAWZ4AAFmhAABZogAAWacAAFm0AABZuQAAWbsAAFm9AABZwgAAWcUAAFnIAABZygAAWe8AAFoTAABaOgAAWl4AAFphAABaYwAAWmUAAFpnAABaaQAAWmsAAFpsAABabwAAWnwAAFqNAABajwAAWpEAAFqTAABalQAAWpcAAFqZAABamwAAWp0AAFquAABasQAAWrQAAFq3AABaugAAWr0AAFrAAABawwAAWsYAAFrIAABbBwAAWwkAAFsLAABbDQAAWxAAAFsRAABbEgAAWxMAAFsUAABbFgAAWxgAAFsZAABbGgAAWxwAAFsdAABbXAAAW14AAFtgAABbYgAAW2UAAFtmAABbZwAAW2gAAFtpAABbawAAW20AAFtuAABbbwAAW3EAAFtyAABbsQAAW7MAAFu2AABbuAAAW7sAAFu8AABbvQAAW74AAFu/AABbwQAAW8MAAFvEAABbxQAAW8cAAFvIAABb1QAAW9YAAFvXAABb2QAAXBgAAFwaAABcHAAAXB4AAFwhAABcIgAAXCMAAFwkAABcJQAAXCcAAFwpAABcKgAAXCsAAFwtAABcLgAAXG0AAFxvAABccQAAXHMAAFx2AABcdwAAXHgAAFx5AABcegAAXHwAAFx+AABcfwAAXIAAAFyCAABcgwAAXMIAAFzEAABcxgAAXMgAAFzLAABczAAAXM0AAFzOAABczwAAXNEAAFzTAABc1AAAXNUAAFzXAABc2AAAXRcAAF0ZAABdGwAAXR0AAF0gAABdIQAAXSIAAF0jAABdJAAAXSYAAF0oAABdKQAAXSoAAF0sAABdLQAAXWwAAF1uAABdcAAAXXIAAF11AABddgAAXXcAAF14AABdeQAAXXsAAF19AABdfgAAXX8AAF2BAABdggAAXacAAF3LAABd8gAAXhYAAF4ZAABeGwAAXh0AAF4fAABeIQAAXiMAAF4kAABeJwAAXjQAAF5DAABeRQAAXkcAAF5JAABeSwAAXk0AAF5PAABeUQAAXmAAAF5jAABeZgAAXmkAAF5sAABebwAAXnIAAF51AABedwAAXrYAAF64AABeugAAXrwAAF6/AABewAAAXsEAAF7CAABewwAAXsUAAF7HAABeyAAAXskAAF7LAABezAAAXwsAAF8NAABfDwAAXxEAAF8UAABfFQAAXxYAAF8XAABfGAAAXxoAAF8cAABfHQAAXx4AAF8gAABfIQAAX2AAAF9iAABfZAAAX2YAAF9pAABfagAAX2sAAF9sAABfbQAAX28AAF9xAABfcgAAX3MAAF91AABfdgAAX7UAAF+3AABfuQAAX7sAAF++AABfvwAAX8AAAF/BAABfwgAAX8QAAF/GAABfxwAAX8gAAF/KAABfywAAYAoAAGAMAABgDgAAYBAAAGATAABgFAAAYBUAAGAWAABgFwAAYBkAAGAbAABgHAAAYB0AAGAfAABgIAAAYF8AAGBhAABgYwAAYGUAAGBoAABgaQAAYGoAAGBrAABgbAAAYG4AAGBwAABgcQAAYHIAAGB0AABgdQAAYLQAAGC2AABguAAAYLoAAGC9AABgvgAAYL8AAGDAAABgwQAAYMMAAGDFAABgxgAAYMcAAGDJAABgygAAYNUAAGDeAABg3wAAYOEAAGDqAABg9QAAYQQAAGEPAABhHQAAYTIAAGFGAABhXQAAYW8AAGGyAABh1gAAYfoAAGIdAABiRAAAYmQAAGKLAABisgAAYtIAAGL2AABjGgAAYxwAAGMfAABjIQAAYyMAAGMlAABjKAAAYysAAGMtAABjLwAAYzIAAGM0AABjNgAAYzkAAGM8AABjPQAAY0IAAGNPAABjUgAAY1QAAGNXAABjWgAAY1wAAGOBAABjpQAAY8wAAGPwAABj8wAAY/UAAGP3AABj+QAAY/sAAGP9AABj/gAAZAEAAGQOAABkIQAAZCMAAGQlAABkJwAAZCkAAGQrAABkLQAAZC8AAGQxAABkMwAAZEYAAGRJAABkTAAAZE8AAGRSAABkVQAAZFgAAGRbAABkXgAAZGEAAGRjAABkogAAZKQAAGSnAABkqQAAZKwAAGStAABkrgAAZK8AAGSwAABksgAAZLQAAGS1AABktgAAZLgAAGS5AABkwgAAZMMAAGTFAABlBAAAZQYAAGUIAABlCgAAZQ0AAGUOAABlDwAAZRAAAGURAABlEwAAZRUAAGUWAABlFwAAZRkAAGUaAABlWQAAZVsAAGVeAABlYAAAZWMAAGVkAABlZQAAZWYAAGVnAABlaQAAZWsAAGVsAABlbQAAZW8AAGVwAABleQAAZXoAAGV8AABluwAAZb0AAGW/AABlwQAAZcQAAGXFAABlxgAAZccAAGXIAABlygAAZcwAAGXNAABlzgAAZdAAAGXRAABmEAAAZhIAAGYVAABmFwAAZhoAAGYbAABmHAAAZh0AAGYeAABmIAAAZiIAAGYjAABmJAAAZiYAAGYnAABmMAAAZjEAAGYzAABmcgAAZnQAAGZ2AABmeAAAZnsAAGZ8AABmfQAAZn4AAGZ/AABmgQAAZoMAAGaEAABmhQAAZocAAGaIAABmxwAAZskAAGbMAABmzgAAZtEAAGbSAABm0wAAZtQAAGbVAABm1wAAZtkAAGbaAABm2wAAZt0AAGbeAABm6wAAZuwAAGbtAABm7wAAZy4AAGcwAABnMgAAZzQAAGc3AABnOAAAZzkAAGc6AABnOwAAZz0AAGc/AABnQAAAZ0EAAGdDAABnRAAAZ4MAAGeFAABniAAAZ4oAAGeNAABnjgAAZ48AAGeQAABnkQAAZ5MAAGeVAABnlgAAZ5cAAGeZAABnmgAAZ60AAGe6AABn5QAAZ+gAAGfrAABn7gAAZ/EAAGf0AABn9wAAZ/oAAGf9AABoAAAAaAMAAGgGAABoCQAAaAwAAGgPAABoEgAAaBUAAGgYAABoGwAAaB0AAGggAABoSwAAaE4AAGhRAABoVAAAaFcAAGhaAABoXQAAaGAAAGhjAABoZgAAaGkAAGhsAABobwAAaHIAAGh1AABoeAAAaHsAAGh+AABogQAAaIQAAGiHAABoiQAAaJIAAGifAABoqQAAaK8AAGi4AABowwAAaM8AAGjeAABo6QAAaPQAAGj9AABpBwAAaRMAAGkdAABpJgAAaS8AAGk6AABpQwAAaUoAAGmVAABpuAAAadgAAGn4AABp+gAAafwAAGn+AABqAAAAagMAAGoEAABqBQAAaggAAGoJAABqCwAAagwAAGoOAABqEQAAahIAAGoTAABqFgAAahcAAGocAABqKQAAai4AAGowAABqMgAAajcAAGo6AABqPQAAaj8AAGpkAABqiAAAaq8AAGrTAABq1gAAatgAAGraAABq3AAAat4AAGrgAABq4QAAauQAAGrxAABrAgAAawQAAGsGAABrCAAAawoAAGsMAABrDgAAaxAAAGsSAABrIwAAayYAAGspAABrLAAAay8AAGsyAABrNQAAazgAAGs7AABrPQAAa3wAAGt+AABrgAAAa4IAAGuFAABrhgAAa4cAAGuIAABriQAAa4sAAGuNAABrjgAAa48AAGuRAABrkgAAa9EAAGvTAABr1QAAa9cAAGvaAABr2wAAa9wAAGvdAABr3gAAa+AAAGviAABr4wAAa+QAAGvmAABr5wAAbCYAAGwoAABsKwAAbC0AAGwwAABsMQAAbDIAAGwzAABsNAAAbDYAAGw4AABsOQAAbDoAAGw8AABsPQAAbEoAAGxLAABsTAAAbE4AAGyNAABsjwAAbJEAAGyTAABslgAAbJcAAGyYAABsmQAAbJoAAGycAABsngAAbJ8AAGygAABsogAAbKMAAGziAABs5AAAbOYAAGzoAABs6wAAbOwAAGztAABs7gAAbO8AAGzxAABs8wAAbPQAAGz1AABs9wAAbPgAAG03AABtOQAAbTsAAG09AABtQAAAbUEAAG1CAABtQwAAbUQAAG1GAABtSAAAbUkAAG1KAABtTAAAbU0AAG2MAABtjgAAbZAAAG2SAABtlQAAbZYAAG2XAABtmAAAbZkAAG2bAABtnQAAbZ4AAG2fAABtoQAAbaIAAG3hAABt4wAAbeUAAG3nAABt6gAAbesAAG3sAABt7QAAbe4AAG3wAABt8gAAbfMAAG30AABt9gAAbfcAAG4cAABuQAAAbmcAAG6LAABujgAAbpAAAG6SAABulAAAbpYAAG6YAABumQAAbpwAAG6pAABuuAAAbroAAG68AABuvgAAbsAAAG7CAABuxAAAbsYAAG7VAABu2AAAbtsAAG7eAABu4QAAbuQAAG7nAABu6gAAbuwAAG8rAABvLQAAbzAAAG8yAABvNQAAbzYAAG83AABvOAAAbzkAAG87AABvPQAAbz4AAG8/AABvQQAAb0IAAG+BAABvgwAAb4UAAG+HAABvigAAb4sAAG+MAABvjQAAb44AAG+QAABvkgAAb5MAAG+UAABvlgAAb5cAAG/WAABv2AAAb9oAAG/cAABv3wAAb+AAAG/hAABv4gAAb+MAAG/lAABv5wAAb+gAAG/pAABv6wAAb+wAAHArAABwLQAAcDAAAHAyAABwNQAAcDYAAHA3AABwOAAAcDkAAHA7AABwPQAAcD4AAHA/AABwQQAAcEIAAHBFAABwhAAAcIYAAHCIAABwigAAcI0AAHCOAABwjwAAcJAAAHCRAABwkwAAcJUAAHCWAABwlwAAcJkAAHCaAABw2QAAcNsAAHDdAABw3wAAcOIAAHDjAABw5AAAcOUAAHDmAABw6AAAcOoAAHDrAABw7AAAcO4AAHDvAABxLgAAcTAAAHEyAABxNAAAcTcAAHE4AABxOQAAcToAAHE7AABxPQAAcT8AAHFAAABxQQAAcUMAAHFEAABxjwAAcbIAAHHSAABx8gAAcfQAAHH2AABx+AAAcfoAAHH9AABx/gAAcf8AAHICAAByAwAAcgUAAHIGAAByCAAAcgsAAHIMAAByDQAAchAAAHIRAAByFgAAciMAAHIoAAByKgAAciwAAHIxAAByNAAAcjcAAHI5AAByXgAAcoIAAHKpAAByzQAActAAAHLSAABy1AAActYAAHLYAABy2gAActsAAHLeAABy6wAAcvwAAHL+AABzAAAAcwIAAHMEAABzBgAAcwgAAHMKAABzDAAAcx0AAHMgAABzIwAAcyYAAHMpAABzLAAAcy8AAHMyAABzNQAAczcAAHN2AABzeAAAc3oAAHN8AABzfwAAc4AAAHOBAABzggAAc4MAAHOFAABzhwAAc4gAAHOJAABziwAAc4wAAHPLAABzzQAAc88AAHPRAABz1AAAc9UAAHPWAABz1wAAc9gAAHPaAABz3AAAc90AAHPeAABz4AAAc+EAAHQgAAB0IgAAdCUAAHQnAAB0KgAAdCsAAHQsAAB0LQAAdC4AAHQwAAB0MgAAdDMAAHQ0AAB0NgAAdDcAAHREAAB0RQAAdEYAAHRIAAB0hwAAdIkAAHSLAAB0jQAAdJAAAHSRAAB0kgAAdJMAAHSUAAB0lgAAdJgAAHSZAAB0mgAAdJwAAHSdAAB03AAAdN4AAHTgAAB04gAAdOUAAHTmAAB05wAAdOgAAHTpAAB06wAAdO0AAHTuAAB07wAAdPEAAHTyAAB1MQAAdTMAAHU1AAB1NwAAdToAAHU7AAB1PAAAdT0AAHU+AAB1QAAAdUIAAHVDAAB1RAAAdUYAAHVHAAB1hgAAdYgAAHWKAAB1jAAAdY8AAHWQAAB1kQAAdZIAAHWTAAB1lQAAdZcAAHWYAAB1mQAAdZsAAHWcAAB12wAAdd0AAHXfAAB14QAAdeQAAHXlAAB15gAAdecAAHXoAAB16gAAdewAAHXtAAB17gAAdfAAAHXxAAB2FgAAdjoAAHZhAAB2hQAAdogAAHaKAAB2jAAAdo4AAHaQAAB2kgAAdpMAAHaWAAB2owAAdrIAAHa0AAB2tgAAdrgAAHa6AAB2vAAAdr4AAHbAAAB2zwAAdtIAAHbVAAB22AAAdtsAAHbeAAB24QAAduQAAHbmAAB3JQAAdycAAHcpAAB3KwAAdy4AAHcvAAB3MAAAdzEAAHcyAAB3NAAAdzYAAHc3AAB3OAAAdzoAAHc7AAB3egAAd3wAAHd+AAB3gAAAd4MAAHeEAAB3hQAAd4YAAHeHAAB3iQAAd4sAAHeMAAB3jQAAd48AAHeQAAB3zwAAd9EAAHfTAAB31QAAd9gAAHfZAAB32gAAd9sAAHfcAAB33gAAd+AAAHfhAAB34gAAd+QAAHflAAB4JAAAeCYAAHgoAAB4KgAAeC0AAHguAAB4LwAAeDAAAHgxAAB4MwAAeDUAAHg2AAB4NwAAeDkAAHg6AAB4eQAAeHsAAHh9AAB4fwAAeIIAAHiDAAB4hAAAeIUAAHiGAAB4iAAAeIoAAHiLAAB4jAAAeI4AAHiPAAB4zgAAeNAAAHjSAAB41AAAeNcAAHjYAAB42QAAeNoAAHjbAAB43QAAeN8AAHjgAAB44QAAeOMAAHjkAAB5IwAAeSUAAHknAAB5KQAAeSwAAHktAAB5LgAAeS8AAHkwAAB5MgAAeTQAAHk1AAB5NgAAeTgAAHk5AAB5hAAAeacAAHnHAAB55wAAeekAAHnrAAB57QAAee8AAHnyAAB58wAAefQAAHn3AAB5+AAAefoAAHn7AAB5/QAAegAAAHoBAAB6AgAAegUAAHoGAAB6CwAAehgAAHodAAB6HwAAeiEAAHomAAB6KQAAeiwAAHouAAB6UwAAencAAHqeAAB6wgAAesUAAHrHAAB6yQAAessAAHrNAAB6zwAAetAAAHrTAAB64AAAevEAAHrzAAB69QAAevcAAHr5AAB6+wAAev0AAHr/AAB7AQAAexIAAHsVAAB7GAAAexsAAHseAAB7IQAAeyQAAHsnAAB7KgAAeywAAHtrAAB7bQAAe28AAHtxAAB7dAAAe3UAAHt2AAB7dwAAe3gAAHt6AAB7fAAAe30AAHt+AAB7gAAAe4EAAHvAAAB7wgAAe8QAAHvGAAB7yQAAe8oAAHvLAAB7zAAAe80AAHvPAAB70QAAe9IAAHvTAAB71QAAe9YAAHwVAAB8FwAAfBoAAHwcAAB8HwAAfCAAAHwhAAB8IgAAfCMAAHwlAAB8JwAAfCgAAHwpAAB8KwAAfCwAAHw5AAB8OgAAfDsAAHw9AAB8fAAAfH4AAHyAAAB8ggAAfIUAAHyGAAB8hwAAfIgAAHyJAAB8iwAAfI0AAHyOAAB8jwAAfJEAAHySAAB80QAAfNMAAHzVAAB81wAAfNoAAHzbAAB83AAAfN0AAHzeAAB84AAAfOIAAHzjAAB85AAAfOYAAHznAAB9JgAAfSgAAH0qAAB9LAAAfS8AAH0wAAB9MQAAfTIAAH0zAAB9NQAAfTcAAH04AAB9OQAAfTsAAH08AAB9ewAAfX0AAH1/AAB9gQAAfYQAAH2FAAB9hgAAfYcAAH2IAAB9igAAfYwAAH2NAAB9jgAAfZAAAH2RAAB90AAAfdIAAH3UAAB91gAAfdkAAH3aAAB92wAAfdwAAH3dAAB93wAAfeEAAH3iAAB94wAAfeUAAH3mAAB+CwAAfi8AAH5WAAB+egAAfn0AAH5/AAB+gQAAfoMAAH6FAAB+hwAAfogAAH6LAAB+mAAAfqcAAH6pAAB+qwAAfq0AAH6vAAB+sQAAfrMAAH61AAB+xAAAfscAAH7KAAB+zQAAftAAAH7TAAB+1gAAftkAAH7bAAB/GgAAfxwAAH8eAAB/IAAAfyMAAH8kAAB/JQAAfyYAAH8nAAB/KQAAfysAAH8sAAB/LQAAfy8AAH8wAAB/bwAAf3EAAH9zAAB/dQAAf3gAAH95AAB/egAAf3sAAH98AAB/fgAAf4AAAH+BAAB/ggAAf4QAAH+FAAB/xAAAf8YAAH/IAAB/ygAAf80AAH/OAAB/zwAAf9AAAH/RAAB/0wAAf9UAAH/WAAB/1wAAf9kAAH/aAACAGQAAgBsAAIAeAACAIAAAgCMAAIAkAACAJQAAgCYAAIAnAACAKQAAgCsAAIAsAACALQAAgC8AAIAwAACAMwAAgHIAAIB0AACAdgAAgHgAAIB7AACAfAAAgH0AAIB+AACAfwAAgIEAAICDAACAhAAAgIUAAICHAACAiAAAgMcAAIDJAACAywAAgM0AAIDQAACA0QAAgNIAAIDTAACA1AAAgNYAAIDYAACA2QAAgNoAAIDcAACA3QAAgRwAAIEeAACBIAAAgSIAAIElAACBJgAAgScAAIEoAACBKQAAgSsAAIEtAACBLgAAgS8AAIExAACBMgAAgX0AAIGgAACBwAAAgeAAAIHiAACB5AAAgeYAAIHoAACB6wAAgewAAIHtAACB8AAAgfEAAIHzAACB9AAAgfYAAIH5AACB+gAAgfsAAIH+AACB/wAAggQAAIIRAACCFgAAghgAAIIaAACCHwAAgiIAAIIlAACCJwAAgkwAAIJwAACClwAAgrsAAIK+AACCwAAAgsIAAILEAACCxgAAgsgAAILJAACCzAAAgtkAAILqAACC7AAAgu4AAILwAACC8gAAgvQAAIL2AACC+AAAgvoAAIMLAACDDgAAgxEAAIMUAACDFwAAgxoAAIMdAACDIAAAgyMAAIMlAACDZAAAg2YAAINoAACDagAAg20AAINuAACDbwAAg3AAAINxAACDcwAAg3UAAIN2AACDdwAAg3kAAIN6AACDuQAAg7sAAIO9AACDvwAAg8IAAIPDAACDxAAAg8UAAIPGAACDyAAAg8oAAIPLAACDzAAAg84AAIPPAACEDgAAhBAAAIQTAACEFQAAhBgAAIQZAACEGgAAhBsAAIQcAACEHgAAhCAAAIQhAACEIgAAhCQAAIQlAACEMgAAhDMAAIQ0AACENgAAhHUAAIR3AACEeQAAhHsAAIR+AACEfwAAhIAAAISBAACEggAAhIQAAISGAACEhwAAhIgAAISKAACEiwAAhMoAAITMAACEzgAAhNAAAITTAACE1AAAhNUAAITWAACE1wAAhNkAAITbAACE3AAAhN0AAITfAACE4AAAhR8AAIUhAACFIwAAhSUAAIUoAACFKQAAhSoAAIUrAACFLAAAhS4AAIUwAACFMQAAhTIAAIU0AACFNQAAhXQAAIV2AACFeAAAhXoAAIV9AACFfgAAhX8AAIWAAACFgQAAhYMAAIWFAACFhgAAhYcAAIWJAACFigAAhckAAIXLAACFzQAAhc8AAIXSAACF0wAAhdQAAIXVAACF1gAAhdgAAIXaAACF2wAAhdwAAIXeAACF3wAAhgQAAIYoAACGTwAAhnMAAIZ2AACGeAAAhnoAAIZ8AACGfgAAhoAAAIaBAACGhAAAhpEAAIagAACGogAAhqQAAIamAACGqAAAhqoAAIasAACGrgAAhr0AAIbAAACGwwAAhsYAAIbJAACGzAAAhs8AAIbSAACG1AAAhxMAAIcVAACHGAAAhxoAAIcdAACHHgAAhx8AAIcgAACHIQAAhyMAAIclAACHJgAAhycAAIcpAACHKgAAh2kAAIdrAACHbQAAh28AAIdyAACHcwAAh3QAAId1AACHdgAAh3gAAId6AACHewAAh3wAAId+AACHfwAAh74AAIfAAACHwgAAh8QAAIfHAACHyAAAh8kAAIfKAACHywAAh80AAIfPAACH0AAAh9EAAIfTAACH1AAAiBMAAIgVAACIGAAAiBoAAIgdAACIHgAAiB8AAIggAACIIQAAiCMAAIglAACIJgAAiCcAAIgpAACIKgAAiGkAAIhrAACIbQAAiG8AAIhyAACIcwAAiHQAAIh1AACIdgAAiHgAAIh6AACIewAAiHwAAIh+AACIfwAAiL4AAIjAAACIwgAAiMQAAIjHAACIyAAAiMkAAIjKAACIywAAiM0AAIjPAACI0AAAiNEAAIjTAACI1AAAiRMAAIkVAACJFwAAiRkAAIkcAACJHQAAiR4AAIkfAACJIAAAiSIAAIkkAACJJQAAiSYAAIkoAACJKQAAiXQAAImXAACJtwAAidcAAInZAACJ2wAAid0AAInfAACJ4gAAieMAAInkAACJ5wAAiegAAInqAACJ6wAAie0AAInwAACJ8QAAifIAAIn1AACJ9gAAifsAAIoIAACKDQAAig8AAIoRAACKFgAAihkAAIocAACKHgAAikMAAIpnAACKjgAAirIAAIq1AACKtwAAirkAAIq7AACKvQAAir8AAIrAAAC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VfAAAlbsAAJW9AACVvwAAlcEAAJXEAACVxQAAlcYAAJXHAACVyAAAlcoAAJXMAACVzQAAlc4AAJXQAACV0QAAlfYAAJYaAACWQQAAlmUAAJZoAACWagAAlmwAAJZuAACWcAAAlnIAAJZzAACWdgAAloMAAJaSAACWlAAAlpYAAJaYAACWmgAAlpwAAJaeAACWoAAAlq8AAJayAACWtQAAlrgAAJa7AACWvgAAlsEAAJbEAACWxgAAlwUAAJcHAACXCgAAlwwAAJcPAACXEAAAlxEAAJcSAACXEwAAlxUAAJcXAACXGAAAlxkAAJcbAACXHAAAlyAAAJdfAACXYQAAl2MAAJdlAACXaAAAl2kAAJdqAACXawAAl2wAAJduAACXcAAAl3EAAJdyAACXdAAAl3UAAJe0AACXtgAAl7gAAJe6AACXvQAAl74AAJe/AACXwAAAl8EAAJfDAACXxQAAl8YAAJfHAACXyQAAl8oAAJgJAACYCwAAmA4AAJgQAACYEwAAmBQAAJgVAACYFgAAmBcAAJgZAACYGwAAmBwAAJgdAACYHwAAmCAAAJhfAACYYQAAmGMAAJhlAACYaAAAmGkAAJhqAACYawAAmGwAAJhuAACYcAAAmHEAAJhyAACYdAAAmHUAAJi0AACYtgAAmLgAAJi6AACYvQAAmL4AAJi/AACYwAAAmMEAAJjDAACYxQAAmMYAAJjHAACYyQAAmMoAAJkJAACZCwAAmQ0AAJkPAACZEgAAmRMAAJkUAACZFQAAmRYAAJkYAACZGgAAmRsAAJkcAACZHgAAmR8AAJlqAACZjQAAma0AAJnNAACZzwAAmdEAAJnTAACZ1QAAmdgAAJnZAACZ2gAAmd0AAJneAACZ4AAAmeEAAJnjAACZ5gAAmecAAJnoAACZ6wAAmewAAJnxAACZ/gAAmgMAAJoFAACaBwAAmgwAAJoPAACaEgAAmhQAAJo5AACaXQAAmoQAAJqoAACaqwAAmq0AAJqvAACasQAAmrMAAJq1AACatgAAmrkAAJrGAACa1wAAmtkAAJrbAACa3QAAmt8AAJrhAACa4wAAmuUAAJrnAACa+AAAmvsAAJr+AACbAQAAmwQAAJsHAACbCgAAmw0AAJsQAACbEgAAm1EAAJtTAACbVQAAm1cAAJtaAACbWwAAm1wAAJtdAACbXgAAm2AAAJtiAACbYwAAm2QAAJtmAACbZwAAm6YAAJuoAACbqgAAm6wAAJuvAACbsAAAm7EAAJuyAACbswAAm7UAAJu3AACbuAAAm7kAAJu7AACbvAAAm/sAAJv9AACcAAAAnAIAAJwFAACcBgAAnAcAAJwIAACcCQAAnAsAAJwNAACcDgAAnA8AAJwRAACcEgAAnB8AAJwgAACcIQAAnCMAAJxiAACcZAAAnGYAAJxoAACcawAAnGwAAJxtAACcbgAAnG8AAJxxAACccwAAnHQAAJx1AACcdwAAnHgAAJy3AACcuQAAnLsAAJy9AACcwAAAnMEAAJzCAACcwwAAnMQAAJzGAACcyAAAnMkAAJzKAACczAAAnM0AAJ0MAACdDgAAnRAAAJ0SAACdFQAAnRYAAJ0XAACdGAAAnRkAAJ0bAACdHQAAnR4AAJ0fAACdIQAAnSIAAJ1hAACdYwAAnWUAAJ1nAACdagAAnWsAAJ1sAACdbQAAnW4AAJ1wAACdcgAAnXMAAJ10AACddgAAnXcAAJ22AACduAAAnboAAJ28AACdvwAAncAAAJ3BAACdwgAAncMAAJ3FAACdxwAAncgAAJ3JAACdywAAncwAAJ3xAACeFQAAnjwAAJ5gAACeYwAAnmUAAJ5nAACeaQAAnmsAAJ5tAACebgAAnnEAAJ5+AACejQAAno8AAJ6RAACekwAAnpUAAJ6XAACemQAAnpsAAJ6qAACerQAAnrAAAJ6zAACetgAAnrkAAJ68AACevwAAnsEAAJ8AAACfAgAAnwQAAJ8GAACfCQAAnwoAAJ8LAACfDAAAnw0AAJ8PAACfEQAAnxIAAJ8TAACfFQAAnxYAAJ9VAACfVwAAn1kAAJ9bAACfXgAAn18AAJ9gAACfYQAAn2IAAJ9kAACfZgAAn2cAAJ9oAACfagAAn2sAAJ+qAACfrAAAn64AAJ+wAACfswAAn7QAAJ+1AACftgAAn7cAAJ+5AACfuwAAn7wAAJ+9AACfvwAAn8AAAJ//AACgAQAAoAMAAKAFAACgCAAAoAkAAKAKAACgCwAAoAwAAKAOAACgEAAAoBEAAKASAACgFAAAoBUAAKBUAACgVgAAoFgAAKBaAACgXQAAoF4AAKBfAACgYAAAoGEAAKBjAACgZQAAoGYAAKBnAACgaQAAoGoAAKCpAACgqwAAoK0AAKCvAACgsgAAoLMAAKC0AACgtQAAoLYAAKC4AACgugAAoLsAAKC8AACgvgAAoL8AAKD+AAChAAAAoQIAAKEEAAChBwAAoQgAAKEJAAChCgAAoQsAAKENAAChDwAAoRAAAKERAAChEwAAoRQAAKFfAAChggAAoaIAAKHCAAChxAAAocYAAKHIAAChygAAoc0AAKHOAAChzwAAodIAAKHTAACh1QAAodYAAKHYAACh2wAAodwAAKHdAACh4AAAoeEAAKHmAACh8wAAofgAAKH6AACh/AAAogEAAKIEAACiBwAAogkAAKIuAACiUgAAonkAAKKdAACioAAAoqIAAKKkAACipgAAoqgAAKKqAACiqwAAoq4AAKK7AACizAAAos4AAKLQAACi0gAAotQAAKLWAACi2AAAotoAAKLcAACi7QAAovAAAKLzAACi9gAAovkAAKL8AACi/wAAowIAAKMFAACjBwAAo0YAAKNIAACjSgAAo0wAAKNPAACjUAAAo1EAAKNSAACjUwAAo1UAAKNXAACjWAAAo1kAAKNbAACjXAAAo5sAAKOdAACjnwAAo6EAAKOkAACjpQAAo6YAAKOnAACjqAAAo6oAAKOsAACjrQAAo64AAKOwAACjsQAAo/AAAKPyAACj9QAAo/cAAKP6AACj+wAAo/wAAKP9AACj/gAApAAAAKQCAACkAwAApAQAAKQGAACkBwAApBQAAKQVAACkFgAApBgAAKRXAACkWQAApFsAAKRdAACkYAAApGEAAKRiAACkYwAApGQAAKRmAACkaAAApGkAAKRqAACkbAAApG0AAKSsAACkrgAApLAAAKSyAACktQAApLYAAKS3AACkuAAApLkAAKS7AACkvQAApL4AAKS/AACkwQAApMIAAKUBAAClAwAApQUAAKUHAAClCgAApQsAAKUMAAClDQAApQ4AAKUQAAClEgAApRMAAKUUAAClFgAApRcAAKVWAAClWAAApVoAAKVcAAClXwAApWAAAKVhAAClYgAApWMAAKVlAAClZwAApWgAAKVpAAClawAApWwAAKWrAAClrQAApa8AAKWxAACltAAApbUAAKW2AACltwAApbgAAKW6AAClvAAApb0AAKW+AAClwAAApcEAAKXmAACmCgAApjEAAKZVAACmWAAAploAAKZcAACmXgAApmAAAKZiAACmYwAApmYAAKZzAACmggAApoQAAKaGAACmiAAApooAAKaMAACmjgAAppAAAKafAACmogAApqUAAKaoAACmqwAApq4AAKaxAACmtAAAprYAAKb1AACm9wAApvkAAKb7AACm/gAApv8AAKcAAACnAQAApwIAAKcEAACnBgAApwcAAKcIAACnCgAApwsAAKdKAACnTAAAp04AAKdQAACnUwAAp1QAAKdVAACnVgAAp1cAAKdZAACnWwAAp1wAAKddAACnXwAAp2AAAKefAACnoQAAp6MAAKelAACnqAAAp6kAAKeqAACnqwAAp6wAAKeuAACnsAAAp7EAAKeyAACntAAAp7UAAKf0AACn9gAAp/gAAKf6AACn/QAAp/4AAKf/AACoAAAAqAEAAKgDAACoBQAAqAYAAKgHAACoCQAAqAoAAKhJAACoSwAAqE0AAKhPAACoUgAAqFMAAKhUAACoVQAAqFYAAKhYAACoWgAAqFsAAKhcAACoXgAAqF8AAKieAACooAAAqKIAAKikAACopwAAqKgAAKipAACoqgAAqKsAAKitAACorwAAqLAAAKixAACoswAAqLQAAKjzAACo9QAAqPcAAKj5AACo/AAAqP0AAKj+AACo/wAAqQAAAKkCAACpBAAAqQUAAKkGAACpCAAAqQkAAKlUAACpdwAAqZcAAKm3AACpuQAAqbsAAKm9AACpvwAAqcIAAKnDAACpxAAAqccAAKnIAACpygAAqcsAAKnNAACp0AAAqdEAAKnSAACp1QAAqdYAAKnbAACp6AAAqe0AAKnvAACp8QAAqfYAAKn5AACp/AAAqf4AAKojAACqRwAAqm4AAKqSAACqlQAAqpcAAKqZAACqmwAAqp0AAKqfAACqoAAAqqMAAKqwAACqwQAAqsMAAKrFAACqxwAAqskAAKrLAACqzQAAqs8AAKrRAACq4gAAquUAAKroAACq6wAAqu4AAKrxAACq9AAAqvcAAKr6AACq/AAAqzsAAKs9AACrPwAAq0EAAKtEAACrRQAAq0YAAKtHAACrSAAAq0oAAKtMAACrTQAAq04AAKtQAACrUQAAq5AAAKuSAACrlAAAq5YAAKuZAACrmgAAq5sAAKucAACrnQAAq58AAKuhAACrogAAq6MAAKulAACrpgAAq+UAAKvnAACr6gAAq+wAAKvvAACr8AAAq/EAAKvyAACr8wAAq/UAAKv3AACr+AAAq/kAAKv7AACr/AAArAkAAKwKAACsCwAArA0AAKxMAACsTgAArFAAAKxSAACsVQAArFYAAKxXAACsWAAArFkAAKxbAACsXQAArF4AAKxfAACsYQAArGIAAKyhAACsowAArKUAAKynAACsqgAArKsAAKysAACsrQAArK4AAKywAACssgAArLMAAKy0AACstgAArLcAAKz2AACs+AAArPoAAKz8AACs/wAArQAAAK0BAACtAgAArQMAAK0FAACtBwAArQgAAK0JAACtCwAArQwAAK1LAACtTQAArU8AAK1RAACtVAAArVUAAK1WAACtVwAArVgAAK1aAACtXAAArV0AAK1eAACtYAAArWEAAK2gAACtogAAraQAAK2mAACtqQAAraoAAK2rAACtrAAAra0AAK2vAACtsQAArbIAAK2zAACttQAArbYAAK3bAACt/wAAriYAAK5KAACuTQAArk8AAK5RAACuUwAArlUAAK5XAACuWAAArlsAAK5oAACudwAArnkAAK57AACufQAArn8AAK6BAACugwAAroUAAK6UAACulwAArpoAAK6dAACuoAAArqMAAK6mAACuqQAArqsAAK7qAACu7AAAru4AAK7wAACu8wAArvQAAK71AACu9gAArvcAAK75AACu+wAArvwAAK79AACu/wAArwAAAK8/AACvQQAAr0MAAK9FAACvSAAAr0kAAK9KAACvSwAAr0wAAK9OAACvUAAAr1EAAK9SAACvVAAAr1UAAK+UAACvlgAAr5gAAK+aAACvnQAAr54AAK+fAACvoAAAr6EAAK+jAACvpQAAr6YAAK+nAACvqQAAr6oAAK/pAACv6wAAr+4AAK/wAACv8wAAr/QAAK/1AACv9gAAr/cAAK/5AACv+wAAr/wAAK/9AACv/wAAsAAAALA/AACwQQAAsEMAALBFAACwSAAAsEkAALBKAACwSwAAsEwAALBOAACwUAAAsFEAALBSAACwVAAAsFUAALCUAACwlgAAsJgAALCaAACwnQAAsJ4AALCfAACwoAAAsKEAALCjAACwpQAAsKYAALCnAACwqQAAsKoAALDpAACw6wAAsO0AALDvAACw8gAAsPMAALD0AACw9QAAsPYAALD4AACw+gAAsPsAALD8AACw/gAAsP8AALFKAACxbQAAsY0AALGtAACxrwAAsbEAALGzAACxtQAAsbgAALG5AACxugAAsb0AALG+AACxwAAAscEAALHDAACxxgAAsccAALHIAACxywAAscwAALHRAACx3gAAseMAALHlAACx5wAAsewAALHvAACx8gAAsfQAALIZAACyPQAAsmQAALKIAACyiwAAso0AALKPAACykQAAspMAALKVAACylgAAspkAALKmAACytwAAsrkAALK7AACyvQAAsr8AALLBAACywwAAssUAALLHAACy2AAAstsAALLeAACy4QAAsuQAALLnAACy6gAAsu0AALLwAACy8gAAszEAALMzAACzNQAAszcAALM6AACzOwAAszwAALM9AACzPgAAs0AAALNCAACzQwAAs0QAALNGAACzRwAAs4YAALOIAACzigAAs4wAALOPAACzkAAAs5EAALOSAACzkwAAs5UAALOXAACzmAAAs5kAALObAACznAAAs9sAALPdAACz4AAAs+IAALPlAACz5gAAs+cAALPoAACz6QAAs+sAALPtAACz7gAAs+8AALPxAACz8gAAs/8AALQAAAC0AQAAtAMAALRCAAC0RAAAtEYAALRIAAC0SwAAtEwAALRNAAC0TgAAtE8AALRRAAC0UwAAtFQAALRVAAC0VwAAtFgAALSXAAC0mQAAtJsAALSdAAC0oAAAtKEAALSiAAC0owAAtKQAALSmAAC0qAAAtKkAALSqAAC0rAAAtK0AALTsAAC07gAAtPAAALTyAAC09QAAtPYAALT3AAC0+AAAtPkAALT7AAC0/QAAtP4AALT/AAC1AQAAtQIAALVBAAC1QwAAtUUAALVHAAC1SgAAtUsAALVMAAC1TQAAtU4AALVQAAC1UgAAtVMAALVUAAC1VgAAtVcAALWWAAC1mAAAtZoAALWcAAC1nwAAtaAAALWhAAC1ogAAtaMAALWlAAC1pwAAtagAALWpAAC1qwAAtawAALXRAAC19QAAthwAALZAAAC2QwAAtkUAALZHAAC2SQAAtksAALZNAAC2TgAAtlEAALZeAAC2bQAAtm8AALZxAAC2cwAAtnUAALZ3AAC2eQAAtnsAALaKAAC2jQAAtpAAALaTAAC2lgAAtpkAALacAAC2nwAAtqEAALbgAAC24gAAtuQAALbmAAC26QAAtuoAALbrAAC27AAAtu0AALbvAAC28QAAtvIAALbzAAC29QAAtvYAALc1AAC3NwAAtzkAALc7AAC3PgAAtz8AALdAAAC3QQAAt0IAALdEAAC3RgAAt0cAALdIAAC3SgAAt0sAALeKAAC3jAAAt44AALeQAAC3kwAAt5QAALeVAAC3lgAAt5cAALeZAAC3mwAAt5wAALedAAC3nwAAt6AAALffAAC34QAAt+MAALflAAC36AAAt+kAALfqAAC36wAAt+wAALfuAAC38AAAt/EAALfyAAC39AAAt/UAALg0AAC4NgAAuDgAALg6AAC4PQAAuD4AALg/AAC4QAAAuEEAALhDAAC4RQAAuEYAALhHAAC4SQAAuEoAALiJAAC4iwAAuI0AALiPAAC4kgAAuJMAALiUAAC4lQAAuJYAALiYAAC4mgAAuJsAALicAAC4ngAAuJ8AALjeAAC44AAAuOIAALjkAAC45wAAuOgAALjpAAC46gAAuOsAALjtAAC47wAAuPAAALjxAAC48wAAuPQAALk/AAC5YgAAuYIAALmiAAC5pAAAuaYAALmoAAC5qgAAua0AALmuAAC5rwAAubIAALmzAAC5tQAAubYAALm4AAC5uwAAubwAALm9AAC5wAAAucEAALnGAAC50wAAudgAALnaAAC53AAAueEAALnkAAC55wAAuekAALoOAAC6MgAAulkAALp9AAC6gAAAuoIAALqEAAC6hgAAuogAALqKAAC6iwAAuo4AALqbAAC6rAAAuq4AALqwAAC6sgAAurQAALq2AAC6uAAAuroAALq8AAC6zQAAutAAALrTAAC61gAAutkAALrcAAC63wAAuuIAALrlAAC65wAAuyYAALsoAAC7KgAAuywAALsvAAC7MAAAuzEAALsyAAC7MwAAuzUAALs3AAC7OAAAuzkAALs7AAC7PAAAu3sAALt9AAC7fwAAu4EAALuEAAC7hQAAu4YAALuHAAC7iAAAu4oAALuMAAC7jQAAu44AALuQAAC7kQAAu9AAALvSAAC71QAAu9cAALvaAAC72wAAu9wAALvdAAC73gAAu+AAALviAAC74wAAu+QAALvmAAC75wAAu/QAALv1AAC79gAAu/gAALw3AAC8OQAAvDsAALw9AAC8QAAAvEEAALxCAAC8QwAAvEQAALxGAAC8SAAAvEkAALxKAAC8TAAAvE0AALyMAAC8jgAAvJAAALySAAC8lQAAvJYAALyXAAC8mAAAvJkAALybAAC8nQAAvJ4AALyfAAC8oQAAvKIAALzhAAC84wAAvOUAALznAAC86gAAvOsAALzsAAC87QAAvO4AALzwAAC88gAAvPMAALz0AAC89gAAvPcAAL02AAC9OAAAvToAAL08AAC9PwAAvUAAAL1BAAC9QgAAvUMAAL1FAAC9RwAAvUgAAL1JAAC9SwAAvUwAAL2LAAC9jQAAvY8AAL2RAAC9lAAAvZUAAL2WAAC9lwAAvZgAAL2aAAC9nAAAvZ0AAL2eAAC9oAAAvaEAAL3GAAC96gAAvhEAAL41AAC+OAAAvjoAAL48AAC+PgAAvkAAAL5CAAC+QwAAvkYAAL5TAAC+YgAAvmQAAL5mAAC+aAAAvmoAAL5sAAC+bgAAvnAAAL5/AAC+ggAAvoUAAL6IAAC+iwAAvo4AAL6RAAC+lAAAvpYAAL7VAAC+1wAAvtoAAL7cAAC+3wAAvuAAAL7hAAC+4gAAvuMAAL7lAAC+5wAAvugAAL7pAAC+6wAAvuwAAL8rAAC/LQAAvy8AAL8xAAC/NAAAvzUAAL82AAC/NwAAvzgAAL86AAC/PAAAvz0AAL8+AAC/QAAAv0EAAL+AAAC/ggAAv4QAAL+GAAC/iQAAv4oAAL+LAAC/jAAAv40AAL+PAAC/kQAAv5IAAL+TAAC/lQAAv5YAAL/VAAC/1wAAv9oAAL/cAAC/3wAAv+AAAL/hAAC/4gAAv+MAAL/lAAC/5wAAv+gAAL/pAAC/6wAAv+wAAMArAADALQAAwC8AAMAxAADANAAAwDUAAMA2AADANwAAwDgAAMA6AADAPAAAwD0AAMA+AADAQAAAwEEAAMCAAADAggAAwIQAAMCGAADAiQAAwIoAAMCLAADAjAAAwI0AAMCPAADAkQAAwJIAAMCTAADAlQAAwJYAAMDVAADA1wAAwNkAAMDbAADA3gAAwN8AAMDgAADA4QAAwOIAAMDkAADA5gAAwOcAAMDoAADA6gAAwOsAAME2AADBWQAAwXkAAMGZAADBmwAAwZ0AAMGfAADBoQAAwaQAAMGlAADBpgAAwakAAMGqAADBrAAAwa0AAMGvAADBsgAAwbMAAMG0AADBtwAAwbgAAMG9AADBygAAwc8AAMHRAADB0wAAwdgAAMHbAADB3gAAweAAAMIFAADCKQAAwlAAAMJ0AADCdwAAwnkAAMJ7AADCfQAAwn8AAMKBAADCggAAwoUAAMKSAADCowAAwqUAAMKnAADCqQAAwqsAAMKtAADCrwAAwrEAAMKzAADCxAAAwscAAMLKAADCzQAAwtAAAMLTAADC1gAAwtkAAMLcAADC3gAAwx0AAMMfAADDIQAAwyMAAMMmAADDJwAAwygAAMMpAADDKgAAwywAAMMuAADDLwAAwzAAAMMyAADDMwAAw3IAAMN0AADDdgAAw3gAAMN7AADDfAAAw30AAMN+AADDfwAAw4EAAMODAADDhAAAw4UAAMOHAADDiAAAw8cAAMPJAADDzAAAw84AAMPRAADD0gAAw9MAAMPUAADD1QAAw9cAAMPZAADD2gAAw9sAAMPdAADD3gAAw+sAAMPsAADD7QAAw+8AAMQuAADEMAAAxDIAAMQ0AADENwAAxDgAAMQ5AADEOgAAxDsAAMQ9AADEPwAAxEAAAMRBAADEQwAAxEQAAMSDAADEhQAAxIcAAMSJAADEjAAAxI0AAMSOAADEjwAAxJAAAMSSAADElAAAxJUAAMSWAADEmAAAxJkAAMTYAADE2gAAxNwAAMTeAADE4QAAxOIAAMTjAADE5AAAxOUAAMTnAADE6QAAxOoAAMTrAADE7QAAxO4AAMUtAADFLwAAxTEAAMUzAADFNgAAxTcAAMU4AADFOQAAxToAAMU8AADFPgAAxT8AAMVAAADFQgAAxUMAAMWCAADFhAAAxYYAAMWIAADFiwAAxYwAAMWNAADFjgAAxY8AAMWRAADFkwAAxZQAAMWVAADFlwAAxZgAAMW9AADF4QAAxggAAMYsAADGLwAAxjEAAMYzAADGNQAAxjcAAMY5AADGOgAAxj0AAMZKAADGWQAAxlsAAMZdAADGXwAAxmEAAMZjAADGZQAAxmcAAMZ2AADGeQAAxnwAAMZ/AADGggAAxoUAAMaIAADGiwAAxo0AAMbMAADGzgAAxtAAAMbSAADG1QAAxtYAAMbXAADG2AAAxtkAAMbbAADG3QAAxt4AAMbfAADG4QAAxuIAAMchAADHIwAAxyUAAMcnAADHKgAAxysAAMcsAADHLQAAxy4AAMcwAADHMgAAxzMAAMc0AADHNgAAxzcAAMd2AADHeAAAx3oAAMd8AADHfwAAx4AAAMeBAADHggAAx4MAAMeFAADHhwAAx4gAAMeJAADHiwAAx4wAAMfLAADHzQAAx88AAMfRAADH1AAAx9UAAMfWAADH1wAAx9gAAMfaAADH3AAAx90AAMfeAADH4AAAx+EAAMggAADIIgAAyCQAAMgmAADIKQAAyCoAAMgrAADILAAAyC0AAMgvAADIMQAAyDIAAMgzAADINQAAyDYAAMh1AADIdwAAyHkAAMh7AADIfgAAyH8AAMiAAADIgQAAyIIAAMiEAADIhgAAyIcAAMiIAADIigAAyIsAAMjKAADIzAAAyM4AAMjQAADI0wAAyNQAAMjVAADI1gAAyNcAAMjZAADI2wAAyNwAAMjdAADI3wAAyOAAAMkrAADJTgAAyW4AAMmOAADJkAAAyZIAAMmUAADJlgAAyZkAAMmaAADJmwAAyZ4AAMmfAADJoQAAyaIAAMmkAADJpwAAyagAAMmpAADJrAAAya0AAMmyAADJvwAAycQAAMnGAADJyAAAyc0AAMnQAADJ0wAAydUAAMn6AADKHgAAykUAAMppAADKbAAAym4AAMpwAADKcgAAynQAAMp2AADKdwAAynoAAMqHAADKmAAAypoAAMqcAADKngAAyqAAAMqiAADKpAAAyqYAAMqoAADKuQAAyrwAAMq/AADKwgAAysUAAMrIAADKywAAys4AAMrRAADK0wAAyxIAAMsUAADLFgAAyxgAAMsbAADLHAAAyx0AAMseAADLHwAAyyEAAMsjAADLJAAAyyUAAMsnAADLKAAAy2cAAMtpAADLawAAy20AAMtwAADLcQAAy3IAAMtzAADLdAAAy3YAAMt4AADLeQAAy3oAAMt8AADLfQAAy7wAAMu+AADLwQAAy8MAAMvGAADLxwAAy8gAAMvJAADLygAAy8wAAMvOAADLzwAAy9AAAMvSAADL0wAAy+AAAMvhAADL4gAAy+QAAMwjAADMJQAAzCcAAMwpAADMLAAAzC0AAMwuAADMLwAAzDAAAMwyAADMNAAAzDUAAMw2AADMOAAAzDkAAMx4AADMegAAzHwAAMx+AADMgQAAzIIAAMyDAADMhAAAzIUAAMyHAADMiQAAzIoAAMyLAADMjQAAzI4AAMzNAADMzwAAzNEAAMzTAADM1gAAzNcAAMzYAADM2QAAzNoAAMzcAADM3gAAzN8AAMzgAADM4gAAzOMAAM0iAADNJAAAzSYAAM0oAADNKwAAzSwAAM0tAADNLgAAzS8AAM0xAADNMwAAzTQAAM01AADNNwAAzTgAAM13AADNeQAAzXsAAM19AADNgAAAzYEAAM2CAADNgwAAzYQAAM2GAADNiAAAzYkAAM2KAADNjAAAzY0AAM2yAADN1gAAzf0AAM4hAADOJAAAziYAAM4oAADOKgAAziwAAM4uAADOLwAAzjIAAM4/AADOTgAAzlAAAM5SAADOVAAAzlYAAM5YAADOWgAAzlwAAM5rAADObgAAznEAAM50AADOdwAAznoAAM59AADOgAAAzoIAAM7BAADOwwAAzsYAAM7IAADOywAAzswAAM7NAADOzgAAzs8AAM7RAADO0wAAztQAAM7VAADO1wAAztgAAM7bAADPGgAAzxwAAM8eAADPIAAAzyMAAM8kAADPJQAAzyYAAM8nAADPKQAAzysAAM8sAADPLQAAzy8AAM8wAADPbwAAz3EAAM9zAADPdQAAz3gAAM95AADPegAAz3sAAM98AADPfgAAz4AAAM+BAADPggAAz4QAAM+FAADPxAAAz8YAAM/JAADPywAAz84AAM/PAADP0AAAz9EAAM/SAADP1AAAz9YAAM/XAADP2AAAz9oAAM/bAADP3gAA0B0AANAfAADQIQAA0CMAANAmAADQJwAA0CgAANApAADQKgAA0CwAANAuAADQLwAA0DAAANAyAADQMwAA0HIAANB0AADQdgAA0HgAANB7AADQfAAA0H0AANB+AADQfwAA0IEAANCDAADQhAAA0IUAANCHAADQiAAA0McAANDJAADQywAA0M0AANDQAADQ0QAA0NIAANDTAADQ1AAA0NYAANDYAADQ2QAA0NoAANDcAADQ3QAA0SgAANFLAADRawAA0YsAANGNAADRjwAA0ZEAANGTAADRlgAA0ZcAANGYAADRmwAA0ZwAANGeAADRnwAA0aEAANGkAADRpQAA0aYAANGpAADRqgAA0bMAANHAAADRxQAA0ccAANHJAADRzgAA0dEAANHUAADR1gAA0fsAANIfAADSRgAA0moAANJtAADSbwAA0nEAANJzAADSdQAA0ncAANJ4AADSewAA0ogAANKZAADSmwAA0p0AANKfAADSoQAA0qMAANKlAADSpwAA0qkAANK6AADSvQAA0sAAANLDAADSxgAA0skAANLMAADSzwAA0tIAANLUAADTEwAA0xUAANMXAADTGQAA0xwAANMdAADTHgAA0x8AANMgAADTIgAA0yQAANMlAADTJgAA0ygAANMpAADTaAAA02oAANNsAADTbgAA03EAANNyAADTcwAA03QAANN1AADTdwAA03kAANN6AADTewAA030AANN+AADTvQAA078AANPCAADTxAAA08cAANPIAADTyQAA08oAANPLAADTzQAA088AANPQAADT0QAA09MAANPUAADT4QAA0+IAANPjAADT5QAA1CQAANQmAADUKAAA1CoAANQtAADULgAA1C8AANQwAADUMQAA1DMAANQ1AADUNgAA1DcAANQ5AADUOgAA1HkAANR7AADUfQAA1H8AANSCAADUgwAA1IQAANSFAADUhgAA1IgAANSKAADUiwAA1IwAANSOAADUjwAA1M4AANTQAADU0gAA1NQAANTXAADU2AAA1NkAANTaAADU2wAA1N0AANTfAADU4AAA1OEAANTjAADU5AAA1SMAANUlAADVJwAA1SkAANUsAADVLQAA1S4AANUvAADVMAAA1TIAANU0AADVNQAA1TYAANU4AADVOQAA1XgAANV6AADVfAAA1X4AANWBAADVggAA1YMAANWEAADVhQAA1YcAANWJAADVigAA1YsAANWNAADVjgAA1bMAANXXAADV/gAA1iIAANYlAADWJwAA1ikAANYrAADWLQAA1i8AANYwAADWMwAA1kAAANZPAADWUQAA1lMAANZVAADWVwAA1lkAANZbAADWXQAA1mwAANZvAADWcgAA1nUAANZ4AADWewAA1n4AANaBAADWgwAA1sIAANbEAADWxgAA1sgAANbLAADWzAAA1s0AANbOAADWzwAA1tEAANbTAADW1AAA1tUAANbXAADW2AAA1xcAANcZAADXGwAA1x0AANcgAADXIQAA1yIAANcjAADXJAAA1yYAANcoAADXKQAA1yoAANcsAADXLQAA12wAANduAADXcAAA13IAANd1AADXdgAA13cAANd4AADXeQAA13sAANd9AADXfgAA138AANeBAADXggAA18EAANfDAADXxgAA18gAANfLAADXzAAA180AANfOAADXzwAA19EAANfTAADX1AAA19UAANfXAADX2AAA2BcAANgZAADYGwAA2B0AANggAADYIQAA2CIAANgjAADYJAAA2CYAANgoAADYKQAA2CoAANgsAADYLQAA2GwAANhuAADYcAAA2HIAANh1AADYdgAA2HcAANh4AADYeQAA2HsAANh9AADYfgAA2H8AANiBAADYggAA2MEAANjDAADYxQAA2McAANjKAADYywAA2MwAANjNAADYzgAA2NAAANjSAADY0wAA2NQAANjWAADY1wAA2SIAANlFAADZZQAA2YUAANmHAADZiQAA2YsAANmNAADZkAAA2ZEAANmSAADZlQAA2ZYAANmYAADZmQAA2ZsAANmeAADZnwAA2aAAANmjAADZpAAA2akAANm2AADZuwAA2b0AANm/AADZxAAA2ccAANnKAADZzAAA2fEAANoVAADaPAAA2mAAANpjAADaZQAA2mcAANppAADaawAA2m0AANpuAADacQAA2n4AANqPAADakQAA2pMAANqVAADalwAA2pkAANqbAADanQAA2p8AANqwAADaswAA2rYAANq5AADavAAA2r8AANrCAADaxQAA2sgAANrKAADbCQAA2wsAANsNAADbDwAA2xIAANsTAADbFAAA2xUAANsWAADbGAAA2xoAANsbAADbHAAA2x4AANsfAADbXgAA22AAANtiAADbZAAA22cAANtoAADbaQAA22oAANtrAADbbQAA228AANtwAADbcQAA23MAANt0AADbswAA27UAANu4AADbugAA270AANu+AADbvwAA28AAANvBAADbwwAA28UAANvGAADbxwAA28kAANvKAADb1wAA29gAANvZAADb2wAA3BoAANwcAADcHgAA3CAAANwjAADcJAAA3CUAANwmAADcJwAA3CkAANwrAADcLAAA3C0AANwvAADcMAAA3G8AANxxAADccwAA3HUAANx4AADceQAA3HoAANx7AADcfAAA3H4AANyAAADcgQAA3IIAANyEAADchQAA3MQAANzGAADcyAAA3MoAANzNAADczgAA3M8AANzQAADc0QAA3NMAANzVAADc1gAA3NcAANzZAADc2gAA3RkAAN0bAADdHQAA3R8AAN0iAADdIwAA3SQAAN0lAADdJgAA3SgAAN0qAADdKwAA3SwAAN0uAADdLwAA3W4AAN1wAADdcgAA3XQAAN13AADdeAAA3XkAAN16AADdewAA3X0AAN1/AADdgAAA3YEAAN2DAADdhAAA3akAAN3NAADd9AAA3hgAAN4bAADeHQAA3h8AAN4hAADeIwAA3iUAAN4mAADeKQAA3jYAAN5FAADeRwAA3kkAAN5LAADeTQAA3k8AAN5RAADeUwAA3mIAAN5lAADeaAAA3msAAN5uAADecQAA3nQAAN53AADeeQAA3rgAAN66AADevQAA3r8AAN7CAADewwAA3sQAAN7FAADexgAA3sgAAN7KAADeywAA3swAAN7OAADezwAA3w4AAN8QAADfEgAA3xQAAN8XAADfGAAA3xkAAN8aAADfGwAA3x0AAN8fAADfIAAA3yEAAN8jAADfJAAA32MAAN9lAADfZwAA32kAAN9sAADfbQAA324AAN9vAADfcAAA33IAAN90AADfdQAA33YAAN94AADfeQAA37gAAN+6AADfvQAA378AAN/CAADfwwAA38QAAN/FAADfxgAA38gAAN/KAADfywAA38wAAN/OAADfzwAA4A4AAOAQAADgEgAA4BQAAOAXAADgGAAA4BkAAOAaAADgGwAA4B0AAOAfAADgIAAA4CEAAOAjAADgJAAA4GMAAOBlAADgZwAA4GkAAOBsAADgbQAA4G4AAOBvAADgcAAA4HIAAOB0AADgdQAA4HYAAOB4AADgeQAA4LgAAOC6AADgvAAA4L4AAODBAADgwgAA4MMAAODEAADgxQAA4McAAODJAADgygAA4MsAAODNAADgzgAA4RkAAOE8AADhXAAA4XwAAOF+AADhgAAA4YIAAOGEAADhhwAA4YgAAOGJAADhjAAA4Y0AAOGPAADhkAAA4ZIAAOGVAADhlgAA4ZcAAOGaAADhmwAA4aAAAOGtAADhsgAA4bQAAOG2AADhuwAA4b4AAOHBAADhwwAA4egAAOIMAADiMwAA4lcAAOJaAADiXAAA4l4AAOJgAADiYgAA4mQAAOJlAADiaAAA4nUAAOKGAADiiAAA4ooAAOKMAADijgAA4pAAAOKSAADilAAA4pYAAOKnAADiqgAA4q0AAOKwAADiswAA4rYAAOK5AADivAAA4r8AAOLBAADjAAAA4wIAAOMEAADjBgAA4wkAAOMKAADjCwAA4wwAAOMNAADjDwAA4xEAAOMSAADjEwAA4xUAAOMWAADjVQAA41cAAONZAADjWwAA414AAONfAADjYAAA42EAAONiAADjZAAA42YAAONnAADjaAAA42oAAONrAADjqgAA46wAAOOvAADjsQAA47QAAOO1AADjtgAA47cAAOO4AADjugAA47wAAOO9AADjvgAA48AAAOPBAADjzgAA488AAOPQAADj0gAA5BEAAOQTAADkFQAA5BcAAOQaAADkGwAA5BwAAOQdAADkHgAA5CAAAOQiAADkIwAA5CQAAOQmAADkJwAA5GYAAORoAADkagAA5GwAAORvAADkcAAA5HEAAORyAADkcwAA5HUAAOR3AADkeAAA5HkAAOR7AADkfAAA5LsAAOS9AADkvwAA5MEAAOTEAADkxQAA5MYAAOTHAADkyAAA5MoAAOTMAADkzQAA5M4AAOTQAADk0QAA5RAAAOUSAADlFAAA5RYAAOUZAADlGgAA5RsAAOUcAADlHQAA5R8AAOUhAADlIgAA5SMAAOUlAADlJgAA5WUAAOVnAADlaQAA5WsAAOVuAADlbwAA5XAAAOVxAADlcgAA5XQAAOV2AADldwAA5XgAAOV6AADlewAA5aAAAOXEAADl6wAA5g8AAOYSAADmFAAA5hYAAOYYAADmGgAA5hwAAOYdAADmIAAA5i0AAOY8AADmPgAA5kAAAOZCAADmRAAA5kYAAOZIAADmSgAA5lkAAOZcAADmXwAA5mIAAOZlAADmaAAA5msAAOZuAADmcAAA5q8AAOaxAADmtAAA5rYAAOa5AADmugAA5rsAAOa8AADmvQAA5r8AAObBAADmwgAA5sMAAObFAADmxgAA5wUAAOcHAADnCQAA5wsAAOcOAADnDwAA5xAAAOcRAADnEgAA5xQAAOcWAADnFwAA5xgAAOcaAADnGwAA51oAAOdcAADnXgAA52AAAOdjAADnZAAA52UAAOdmAADnZwAA52kAAOdrAADnbAAA520AAOdvAADncAAA568AAOexAADntAAA57YAAOe5AADnugAA57sAAOe8AADnvQAA578AAOfBAADnwgAA58MAAOfFAADnxgAA6AUAAOgHAADoCQAA6AsAAOgOAADoDwAA6BAAAOgRAADoEgAA6BQAAOgWAADoFwAA6BgAAOgaAADoGwAA6FoAAOhcAADoXgAA6GAAAOhjAADoZAAA6GUAAOhmAADoZwAA6GkAAOhrAADobAAA6G0AAOhvAADocAAA6K8AAOixAADoswAA6LUAAOi4AADouQAA6LoAAOi7AADovAAA6L4AAOjAAADowQAA6MIAAOjEAADoxQAA6RAAAOkzAADpUwAA6XMAAOl1AADpdwAA6XkAAOl7AADpfgAA6X8AAOmAAAD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FVQABBVcAAQVZAAEFWwABBV4AAQVfAAEFYAABBWEAAQViAAEFZAABBWYAAQVnAAEFaAABBWoAAQVrAAEFkAABBbQAAQXbAAEF/wABBgIAAQYEAAEGBgABBggAAQYKAAEGDAABBg0AAQYQAAEGHQABBiwAAQYuAAEGMAABBjIAAQY0AAEGNgABBjgAAQY6AAEGSQABBkwAAQZPAAEGUgABBlUAAQZYAAEGWwABBl4AAQZgAAEGnwABBqEAAQakAAEGpgABBqkAAQaqAAEGqwABBqwAAQatAAEGrwABBrEAAQayAAEGswABBrUAAQa2AAEG9QABBvcAAQb5AAEG+wABBv4AAQb/AAEHAAABBwEAAQcCAAEHBAABBwYAAQcHAAEHCAABBwoAAQcLAAEHSgABB0wAAQdOAAEHUAABB1MAAQdUAAEHVQABB1YAAQdXAAEHWQABB1sAAQdcAAEHXQABB18AAQdgAAEHnwABB6EAAQekAAEHpgABB6kAAQeqAAEHqwABB6wAAQetAAEHrwABB7EAAQeyAAEHswABB7UAAQe2AAEH9QABB/cAAQf5AAEH+wABB/4AAQf/AAEIAAABCAEAAQgCAAEIBAABCAYAAQgHAAEICAABCAoAAQgLAAEISgABCEwAAQhOAAEIUAABCFMAAQhUAAEIVQABCFYAAQhXAAEIWQABCFsAAQhcAAEIXQABCF8AAQhgAAEInwABCKEAAQijAAEIpQABCKgAAQipAAEIqgABCKsAAQisAAEIrgABCLAAAQixAAEIsgABCLQAAQi1AAEIvgABCL8AAQjBAAEJBAABCSgAAQlMAAEJbwABCZYAAQm2AAEJ3QABCgQAAQokAAEKSAABCmwAAQpuAAEKcQABCnMAAQp1AAEKdwABCnoAAQp9AAEKfwABCoEAAQqEAAEKhgABCogAAQqLAAEKjgABCo8AAQqUAAEKoQABCqQAAQqmAAEKqQABCqwAAQquAAEK0wABCvcAAQseAAELQgABC0UAAQtHAAELSQABC0sAAQtNAAELTwABC1AAAQtTAAELYAABC3MAAQt1AAELdwABC3kAAQt7AAELfQABC38AAQuBAAELgwABC4UAAQuYAAELmwABC54AAQuhAAELpAABC6cAAQuqAAELrQABC7AAAQuzAAELtQABC/QAAQv2AAEL+QABC/sAAQv+AAEL/wABDAAAAQwBAAEMAgABDAQAAQwGAAEMBwABDAgAAQwKAAEMCwABDBQAAQwVAAEMFwABDFYAAQxYAAEMWgABDFwAAQxfAAEMYAABDGEAAQxiAAEMYwABDGUAAQxnAAEMaAABDGkAAQxrAAEMbAABDKsAAQytAAEMsAABDLIAAQy1AAEMtgABDLcAAQy4AAEMuQABDLsAAQy9AAEMvgABDL8AAQzBAAEMwgABDMsAAQzMAAEMzgABDQ0AAQ0PAAENEQABDRMAAQ0WAAENFwABDRgAAQ0ZAAENGgABDRwAAQ0eAAENHwABDSAAAQ0iAAENIwABDWIAAQ1kAAENZwABDWkAAQ1sAAENbQABDW4AAQ1vAAENcAABDXIAAQ10AAENdQABDXYAAQ14AAENeQABDYIAAQ2DAAENhQABDcQAAQ3GAAENyAABDcoAAQ3NAAENzgABDc8AAQ3QAAEN0QABDdMAAQ3VAAEN1gABDdcAAQ3ZAAEN2gABDhkAAQ4bAAEOHgABDiAAAQ4jAAEOJAABDiUAAQ4mAAEOJwABDikAAQ4rAAEOLAABDi0AAQ4vAAEOMAABDj0AAQ4+AAEOPwABDkEAAQ6AAAEOggABDoQAAQ6GAAEOiQABDooAAQ6LAAEOjAABDo0AAQ6PAAEOkQABDpIAAQ6TAAEOlQABDpYAAQ7VAAEO1wABDtoAAQ7cAAEO3wABDuAAAQ7hAAEO4gABDuMAAQ7lAAEO5wABDugAAQ7pAAEO6wABDuwAAQ73AAEPBAABDx8AAQ8iAAEPJQABDygAAQ8rAAEPLgABDzEAAQ8zAAEPNgABDzkAAQ88AAEPPwABD0IAAQ9FAAEPYAABD2MAAQ9mAAEPaQABD2wAAQ9vAAEPcgABD3UAAQ94AAEPewABD34AAQ+BAAEPhAABD4cAAQ+JAAEPlAABD6EAAQ+nAAEPsgABD70AARAIAAEQKwABEEsAARBrAAEQbQABEG8AARBxAAEQcwABEHYAARB3AAEQeAABEHsAARB8AAEQfgABEH8AARCBAAEQhAABEIUAARCGAAEQiQABEIoAARCPAAEQnAABEKEAARCjAAEQpQABEKoAARCtAAEQsAABELIAARDXAAEQ+wABESIAARFGAAERSQABEUsAARFNAAERTwABEVEAARFTAAERVAABEVcAARFkAAERdQABEXcAARF5AAERewABEX0AARF/AAERgQABEYMAARGFAAERlgABEZkAARGcAAERnwABEaIAARGlAAERqAABEasAARGuAAERsAABEe8AARHxAAER8wABEfUAARH4AAER+QABEfoAARH7AAER/AABEf4AARIAAAESAQABEgIAARIEAAESBQABEkQAARJGAAESSAABEkoAARJNAAESTgABEk8AARJQAAESUQABElMAARJVAAESVgABElcAARJZAAESWgABEpkAARKbAAESngABEqAAARKjAAESpAABEqUAARKmAAESpwABEqkAARKrAAESrAABEq0AARKvAAESsAABEr0AARK+AAESvwABEsEAARMAAAETAgABEwQAARMGAAETCQABEwoAARMLAAETDAABEw0AARMPAAETEQABExIAARMTAAETFQABExYAARNVAAETVwABE1kAARNbAAETXgABE18AARNgAAETYQABE2IAARNkAAETZgABE2cAARNoAAETagABE2sAAROqAAETrAABE64AAROwAAETswABE7QAARO1AAETtgABE7cAARO5AAETuwABE7wAARO9AAETvwABE8AAARP/AAEUAQABFAMAARQFAAEUCAABFAkAARQKAAEUCwABFAwAARQOAAEUEAABFBEAARQSAAEUFAABFBUAARRUAAEUVgABFFgAARRaAAEUXQABFF4AARRfAAEUYAABFGEAARRjAAEUZQABFGYAARRnAAEUaQABFGoAARSPAAEUswABFNoAART+AAEVAQABFQMAARUFAAEVBwABFQkAARULAAEVDAABFQ8AARUcAAEVKwABFS0AARUvAAEVMQABFTMAARU1AAEVNwABFTkAARVIAAEVSwABFU4AARVRAAEVVAABFVcAARVaAAEVXQABFV8AARWeAAEVoAABFaMAARWlAAEVqAABFakAARWqAAEVqwABFawAARWuAAEVsAABFbEAARWyAAEVtAABFbUAARX0AAEV9gABFfgAARX6AAEV/QABFf4AARX/AAEWAAABFgEAARYDAAEWBQABFgYAARYHAAEWCQABFgoAARZJAAEWSwABFk0AARZPAAEWUgABFlMAARZUAAEWVQABFlYAARZYAAEWWgABFlsAARZcAAEWXgABFl8AARaeAAEWoAABFqMAARalAAEWqAABFqkAARaqAAEWqwABFqwAARauAAEWsAABFrEAARayAAEWtAABFrUAARb0AAEW9gABFvgAARb6AAEW/QABFv4AARb/AAEXAAABFwEAARcDAAEXBQABFwYAARcHAAEXCQABFwoAARdJAAEXSwABF00AARdPAAEXUgABF1MAARdUAAEXVQABF1YAARdYAAEXWgABF1sAARdcAAEXXgABF18AAReeAAEXoAABF6IAARekAAEXpwABF6gAARepAAEXqgABF6sAARetAAEXrwABF7AAARexAAEXswABF7QAARf/AAEYIgABGEIAARhiAAEYZAABGGYAARhoAAEYagABGG0AARhuAAEYbwABGHIAARhzAAEYdQABGHYAARh4AAEYewABGHwAARh9AAEYgAABGIEAARiKAAEYlwABGJwAARieAAEYoAABGKUAARioAAEYqwABGK0AARjSAAEY9gABGR0AARlBAAEZRAABGUYAARlIAAEZSgABGUwAARlOAAEZTwABGVIAARlfAAEZcAABGXIAARl0AAEZdgABGXgAARl6AAEZfAABGX4AARmAAAEZkQABGZQAARmXAAEZmgABGZ0AARmgAAEZowABGaYAARmpAAEZqwABGeoAARnsAAEZ7gABGfAAARnzAAEZ9AABGfUAARn2AAEZ9wABGfkAARn7AAEZ/AABGf0AARn/AAEaAAABGj8AARpBAAEaQwABGkUAARpIAAEaSQABGkoAARpLAAEaTAABGk4AARpQAAEaUQABGlIAARpUAAEaVQABGpQAARqWAAEamQABGpsAARqeAAEanwABGqAAARqhAAEaogABGqQAARqmAAEapwABGqgAARqqAAEaqwABGrgAARq5AAEaugABGrwAARr7AAEa/QABGv8AARsBAAEbBAABGwUAARsGAAEbBwABGwgAARsKAAEbDAABGw0AARsOAAEbEAABGxEAARtQAAEbUgABG1QAARtWAAEbWQABG1oAARtbAAEbXAABG10AARtfAAEbYQABG2IAARtjAAEbZQABG2YAARulAAEbpwABG6kAARurAAEbrgABG68AARuwAAEbsQABG7IAARu0AAEbtgABG7cAARu4AAEbugABG7sAARv6AAEb/AABG/4AARwAAAEcAwABHAQAARwFAAEcBgABHAcAARwJAAEcCwABHAwAARwNAAEcDwABHBAAARxPAAEcUQABHFMAARxVAAEcWAABHFkAARxaAAEcWwABHFwAARxeAAEcYAABHGEAARxiAAEcZAABHGUAARyKAAEcrgABHNUAARz5AAEc/AABHP4AAR0AAAEdAgABHQQAAR0GAAEdBwABHQoAAR0XAAEdJgABHSgAAR0qAAEdLAABHS4AAR0wAAEdMgABHTQAAR1DAAEdRgABHUkAAR1MAAEdTwABHVIAAR1VAAEdWAABHVoAAR2ZAAEdmwABHZ4AAR2gAAEdowABHaQAAR2lAAEdpgABHacAAR2pAAEdqwABHawAAR2tAAEdrwABHbAAAR3vAAEd8QABHfMAAR31AAEd+AABHfkAAR36AAEd+wABHfwAAR3+AAEeAAABHgEAAR4CAAEeBAABHgUAAR5EAAEeRgABHkgAAR5KAAEeTQABHk4AAR5PAAEeUAABHlEAAR5TAAEeVQABHlYAAR5XAAEeWQABHloAAR6ZAAEemwABHp4AAR6gAAEeowABHqQAAR6lAAEepgABHqcAAR6pAAEeqwABHqwAAR6tAAEerwABHrAAAR7vAAEe8QABHvMAAR71AAEe+AABHvkAAR76AAEe+wABHvwAAR7+AAEfAAABHwEAAR8CAAEfBAABHwUAAR9EAAEfRgABH0gAAR9KAAEfTQABH04AAR9PAAEfUAABH1EAAR9TAAEfVQABH1YAAR9XAAEfWQABH1oAAR+ZAAEfmwABH50AAR+fAAEfogABH6MAAR+kAAEfpQABH6YAAR+oAAEfqgABH6sAAR+sAAEfrgABH68AAR/6AAEgHQABID0AASBdAAEgXwABIGEAASBjAAEgZQABIGgAASBpAAEgagABIG0AASBuAAEgcAABIHEAASBzAAEgdgABIHcAASB4AAEgewABIHwAASCBAAEgjgABIJMAASCVAAEglwABIJwAASCfAAEgogABIKQAASDJAAEg7QABIRQAASE4AAEhOwABIT0AASE/AAEhQQABIUMAASFFAAEhRgABIUkAASFWAAEhZwABIWkAASFrAAEhbQABIW8AASFxAAEhcwABIXUAASF3AAEhiAABIYsAASGOAAEhkQABIZQAASGXAAEhmgABIZ0AASGgAAEhogABIeEAASHjAAEh5QABIecAASHqAAEh6wABIewAASHtAAEh7gABIfAAASHyAAEh8wABIfQAASH2AAEh9wABIjYAASI4AAEiOgABIjwAASI/AAEiQAABIkEAASJCAAEiQwABIkUAASJHAAEiSAABIkkAASJLAAEiTAABIosAASKNAAEikAABIpIAASKVAAEilgABIpcAASKYAAEimQABIpsAASKdAAEingABIp8AASKhAAEiogABIq8AASKwAAEisQABIrMAASLyAAEi9AABIvYAASL4AAEi+wABIvwAASL9AAEi/gABIv8AASMBAAEjAwABIwQAASMFAAEjBwABIwgAASNHAAEjSQABI0sAASNNAAEjUAABI1EAASNSAAEjUwABI1QAASNWAAEjWAABI1kAASNaAAEjXAABI10AASOcAAEjngABI6AAASOiAAEjpQABI6YAASOnAAEjqAABI6kAASOrAAEjrQABI64AASOvAAEjsQABI7IAASPxAAEj8wABI/UAASP3AAEj+gABI/sAASP8AAEj/QABI/4AASQAAAEkAgABJAMAASQEAAEkBgABJAcAASRGAAEkSAABJEoAASRMAAEkTwABJFAAASRRAAEkUgABJFMAASRVAAEkVwABJFgAASRZAAEkWwABJFwAASSBAAEkpQABJMwAASTwAAEk8wABJPUAAST3AAEk+QABJPsAAST9AAEk/gABJQEAASUOAAElHQABJR8AASUhAAElIwABJSUAASUnAAElKQABJSsAASU6AAElPQABJUAAASVDAAElRgABJUkAASVMAAElTwABJVEAASWQAAElkgABJZUAASWXAAElmgABJZsAASWcAAElnQABJZ4AASWgAAElogABJaMAASWkAAElpgABJacAASW2AAEl9QABJfcAASX5AAEl+wABJf4AASX/AAEmAAABJgEAASYCAAEmBAABJgYAASYHAAEmCAABJgoAASYLAAEmSgABJkwAASZOAAEmUAABJlMAASZUAAEmVQABJlYAASZXAAEmWQABJlsAASZcAAEmXQABJl8AASZgAAEmnwABJqEAASakAAEmpgABJqkAASaqAAEmqwABJqwAASatAAEmrwABJrEAASayAAEmswABJrUAASa2AAEm9QABJvcAASb5AAEm+wABJv4AASb/AAEnAAABJwEAAScCAAEnBAABJwYAAScHAAEnCAABJwoAAScLAAEnSgABJ0wAASdOAAEnUAABJ1MAASdUAAEnVQABJ1YAASdXAAEnWQABJ1sAASdcAAEnXQABJ18AASdgAAEnnwABJ6EAASejAAEnpQABJ6gAASepAAEnqgABJ6sAASesAAEnrgABJ7AAASexAAEnsgABJ7QAASe1AAEoAAABKCMAAShDAAEoYwABKGUAAShnAAEoaQABKGsAAShuAAEobwABKHAAAShzAAEodAABKHYAASh3AAEoeQABKHwAASh9AAEofgABKIEAASiCAAEohwABKJQAASiZAAEomwABKJ0AASiiAAEopQABKKgAASiqAAEozwABKPMAASkaAAEpPgABKUEAASlDAAEpRQABKUcAASlJAAEpSwABKUwAASlPAAEpXAABKW0AASlvAAEpcQABKXMAASl1AAEpdwABKXkAASl7AAEpfQABKY4AASmRAAEplAABKZcAASmaAAEpnQABKaAAASmjAAEppgABKagAASnnAAEp6QABKesAASntAAEp8AABKfEAASnyAAEp8wABKfQAASn2AAEp+AABKfkAASn6AAEp/AABKf0AASo8AAEqPgABKkAAASpCAAEqRQABKkYAASpHAAEqSAABKkkAASpLAAEqTQABKk4AASpPAAEqUQABKlIAASqRAAEqkwABKpYAASqYAAEqmwABKpwAASqdAAEqngABKp8AASqhAAEqowABKqQAASqlAAEqpwABKqgAASq1AAEqtgABKrcAASq5AAEq+AABKvoAASr8AAEq/gABKwEAASsCAAErAwABKwQAASsFAAErBwABKwkAASsKAAErCwABKw0AASsOAAErTQABK08AAStRAAErUwABK1YAAStXAAErWAABK1kAAStaAAErXAABK14AAStfAAErYAABK2IAAStjAAErogABK6QAASumAAErqAABK6sAASusAAErrQABK64AASuvAAErsQABK7MAASu0AAErtQABK7cAASu4AAEr9wABK/kAASv7AAEr/QABLAAAASwBAAEsAgABLAMAASwEAAEsBgABLAgAASwJAAEsCgABLAwAASwNAAEsTAABLE4AASxQAAEsUgABLFUAASxWAAEsVwABLFgAASxZAAEsWwABLF0AASxeAAEsXwABLGEAASxiAAEshwABLKsAASzSAAEs9gABLPkAASz7AAEs/QABLP8AAS0BAAEtAwABLQQAAS0HAAEtFAABLSMAAS0lAAEtJwABLSkAAS0rAAEtLQABLS8AAS0xAAEtQAABLUMAAS1GAAEtSQABLUwAAS1PAAEtUgABLVUAAS1XAAEtlgABLZgAAS2aAAEtnAABLZ8AAS2gAAEtoQABLaIAAS2jAAEtpQABLacAAS2oAAEtqQABLasAAS2sAAEt6wABLe0AAS3vAAEt8QABLfQAAS31AAEt9gABLfcAAS34AAEt+gABLfwAAS39AAEt/gABLgAAAS4BAAEuQAABLkIAAS5EAAEuRgABLkkAAS5KAAEuSwABLkwAAS5NAAEuTwABLlEAAS5SAAEuUwABLlUAAS5WAAEulQABLpcAAS6aAAEunAABLp8AAS6gAAEuoQABLqIAAS6jAAEupQABLqcAAS6oAAEuqQABLqsAAS6sAAEu6wABLu0AAS7vAAEu8QABLvQAAS71AAEu9gABLvcAAS74AAEu+gABLvwAAS79AAEu/gABLwAAAS8BAAEvQAABL0IAAS9EAAEvRgABL0kAAS9KAAEvSwABL0wAAS9NAAEvTwABL1EAAS9SAAEvUwABL1UAAS9WAAEvlQABL5cAAS+ZAAEvmwABL54AAS+fAAEvoAABL6EAAS+iAAEvpAABL6YAAS+nAAEvqAABL6oAAS+rAAEv9gABMBkAATA5AAEwWQABMFsAATBdAAEwXwABMGEAATBkAAEwZQABMGYAATBpAAEwagABMGwAATBtAAEwbwABMHIAATBzAAEwdAABMHcAATB4AAEwfQABMIoAATCPAAEwkQABMJMAATCYAAEwmwABMJ4AATCgAAEwxQABMOkAATEQAAExNAABMTcAATE5AAExOwABMT0AATE/AAExQQABMUIAATFFAAExUgABMWMAATFlAAExZwABMWkAATFrAAExbQABMW8AATFxAAExcwABMYQAATGHAAExigABMY0AATGQAAExkwABMZYAATGZAAExnAABMZ4AATHdAAEx3wABMeEAATHjAAEx5gABMecAATHoAAEx6QABMeoAATHsAAEx7gABMe8AATHwAAEx8gABMfMAATIyAAEyNAABMjYAATI4AAEyOwABMjwAATI9AAEyPgABMj8AATJBAAEyQwABMkQAATJFAAEyRwABMkgAATKHAAEyiQABMowAATKOAAEykQABMpIAATKTAAEylAABMpUAATKXAAEymQABMpoAATKbAAEynQABMp4AATKrAAEyrAABMq0AATKvAAEy7gABMvAAATLyAAEy9AABMvcAATL4AAEy+QABMvoAATL7AAEy/QABMv8AATMAAAEzAQABMwMAATMEAAEzQwABM0UAATNHAAEzSQABM0wAATNNAAEzTgABM08AATNQAAEzUgABM1QAATNVAAEzVgABM1gAATNZAAEzmAABM5oAATOcAAEzngABM6EAATOiAAEzowABM6QAATOlAAEzpwABM6kAATOqAAEzqwABM60AATOuAAEz7QABM+8AATPxAAEz8wABM/YAATP3AAEz+AABM/kAATP6AAEz/AABM/4AATP/AAE0AAABNAIAATQDAAE0QgABNEQAATRGAAE0SAABNEsAATRMAAE0TQABNE4AATRPAAE0UQABNFMAATRUAAE0VQABNFcAATRYAAE0fQABNKEAATTIAAE07AABNO8AATTxAAE08wABNPUAATT3AAE0+QABNPoAATT9AAE1CgABNRkAATUbAAE1HQABNR8AATUhAAE1IwABNSUAATUnAAE1NgABNTkAATU8AAE1PwABNUIAATVFAAE1SAABNUsAATVNAAE1jAABNY4AATWQAAE1kgABNZUAATWWAAE1lwABNZgAATWZAAE1mwABNZ0AATWeAAE1nwABNaEAATWiAAE14QABNeMAATXlAAE15wABNeoAATXrAAE17AABNe0AATXuAAE18AABNfIAATXzAAE19AABNfYAATX3AAE2NgABNjgAATY6AAE2PAABNj8AATZAAAE2QQABNkIAATZDAAE2RQABNkcAATZIAAE2SQABNksAATZMAAE2iwABNo0AATaQAAE2kgABNpUAATaWAAE2lwABNpgAATaZAAE2mwABNp0AATaeAAE2nwABNqEAATaiAAE24QABNuMAATblAAE25wABNuoAATbrAAE27AABNu0AATbuAAE28AABNvIAATbzAAE29AABNvYAATb3AAE3NgABNzgAATc6AAE3PAABNz8AATdAAAE3QQABN0IAATdDAAE3RQABN0cAATdIAAE3SQABN0sAATdMAAE3iwABN40AATePAAE3kQABN5QAATeVAAE3lgABN5cAATeYAAE3mgABN5wAATedAAE3ngABN6AAATehAAE37AABOA8AATgvAAE4TwABOFEAAThTAAE4VQABOFcAAThaAAE4WwABOFwAAThfAAE4YAABOGIAAThjAAE4ZQABOGgAAThpAAE4agABOG0AAThuAAE4dwABOIQAATiJAAE4iwABOI0AATiSAAE4lQABOJgAATiaAAE4vwABOOMAATkKAAE5LgABOTEAATkzAAE5NQABOTcAATk5AAE5OwABOTwAATk/AAE5TAABOV0AATlfAAE5YQABOWMAATllAAE5ZwABOWkAATlrAAE5bQABOX4AATmBAAE5hAABOYcAATmKAAE5jQABOZAAATmTAAE5lgABOZgAATnXAAE52QABOdsAATndAAE54AABOeEAATniAAE54wABOeQAATnmAAE56AABOekAATnqAAE57AABOe0AATosAAE6LgABOjAAAToyAAE6NQABOjYAATo3AAE6OAABOjkAATo7AAE6PQABOj4AATo/AAE6QQABOkIAATqBAAE6gwABOoYAATqIAAE6iwABOowAATqNAAE6jgABOo8AATqRAAE6kwABOpQAATqVAAE6lwABOpgAATqlAAE6pgABOqcAATqpAAE66AABOuoAATrsAAE67gABOvEAATryAAE68wABOvQAATr1AAE69wABOvkAATr6AAE6+wABOv0AATr+AAE7PQABOz8AATtBAAE7QwABO0YAATtHAAE7SAABO0kAATtKAAE7TAABO04AATtPAAE7UAABO1IAATtTAAE7kgABO5QAATuWAAE7mAABO5sAATucAAE7nQABO54AATufAAE7oQABO6MAATukAAE7pQABO6cAATuoAAE75wABO+kAATvrAAE77QABO/AAATvxAAE78gABO/MAATv0AAE79gABO/gAATv5AAE7+gABO/wAATv9AAE8PAABPD4AATxAAAE8QgABPEUAATxGAAE8RwABPEgAATxJAAE8SwABPE0AATxOAAE8TwABPFEAATxSAAE8dwABPJsAATzCAAE85gABPOkAATzrAAE87QABPO8AATzxAAE88wABPPQAATz3AAE9BAABPRMAAT0VAAE9FwABPRkAAT0bAAE9HQABPR8AAT0hAAE9MAABPTMAAT02AAE9OQABPTwAAT0/AAE9QgABPUUAAT1HAAE9hgABPYgAAT2LAAE9jQABPZAAAT2RAAE9kgABPZMAAT2UAAE9lgABPZgAAT2ZAAE9mgABPZwAAT2dAAE93AABPd4AAT3gAAE94gABPeUAAT3mAAE95wABPegAAT3pAAE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OhwABTokAAU6KAAFOyQABTssAAU7NAAFOzwABTtIAAU7TAAFO1AABTtUAAU7WAAFO2AABTtoAAU7bAAFO3AABTt4AAU7fAAFPHgABTyAAAU8iAAFPJAABTycAAU8oAAFPKQABTyoAAU8rAAFPLQABTy8AAU8wAAFPMQABTzMAAU80AAFPcwABT3UAAU93AAFPeQABT3wAAU99AAFPfgABT38AAU+AAAFPggABT4QAAU+FAAFPhgABT4gAAU+JAAFP1AABT/cAAVAXAAFQNwABUDkAAVA7AAFQPQABUD8AAVBCAAFQQwABUEQAAVBHAAFQSAABUEoAAVBLAAFQTQABUFAAAVBRAAFQUgABUFUAAVBWAAFQWwABUGgAAVBtAAFQbwABUHEAAVB2AAFQeQABUHwAAVB+AAFQowABUMcAAVDuAAFREgABURUAAVEXAAFRGQABURsAAVEdAAFRHwABUSAAAVEjAAFRMAABUUEAAVFDAAFRRQABUUcAAVFJAAFRSwABUU0AAVFPAAFRUQABUWIAAVFlAAFRaAABUWsAAVFuAAFRcQABUXQAAVF3AAFRegABUXwAAVG7AAFRvQABUb8AAVHBAAFRxAABUcUAAVHGAAFRxwABUcgAAVHKAAFRzAABUc0AAVHOAAFR0AABUdEAAVIQAAFSEgABUhQAAVIWAAFSGQABUhoAAVIbAAFSHAABUh0AAVIfAAFSIQABUiIAAVIjAAFSJQABUiYAAVJlAAFSZwABUmoAAVJsAAFSbwABUnAAAVJxAAFScgABUnMAAVJ1AAFSdwABUngAAVJ5AAFSewABUnwAAVKJAAFSigABUosAAVKNAAFSzAABUs4AAVLQAAFS0gABUtUAAVLWAAFS1wABUtgAAVLZAAFS2wABUt0AAVLeAAFS3wABUuEAAVLiAAFTIQABUyMAAVMlAAFTJwABUyoAAVMrAAFTLAABUy0AAVMuAAFTMAABUzIAAVMzAAFTNAABUzYAAVM3AAFTdgABU3gAAVN6AAFTfAABU38AAVOAAAFTgQABU4IAAVODAAFThQABU4cAAVOIAAFTiQABU4sAAVOMAAFTywABU80AAVPPAAFT0QABU9QAAVPVAAFT1gABU9cAAVPYAAFT2gABU9wAAVPdAAFT3gABU+AAAVPhAAFUIAABVCIAAVQkAAFUJgABVCkAAVQqAAFUKwABVCwAAVQtAAFULwABVDEAAVQyAAFUMwABVDUAAVQ2AAFUWwABVH8AAVSmAAFUygABVM0AAVTPAAFU0QABVNMAAVTVAAFU1wABVNgAAVTbAAFU6AABVPcAAVT5AAFU+wABVP0AAVT/AAFVAQABVQMAAVUFAAFVFAABVRcAAVUaAAFVHQABVSAAAVUjAAFVJgABVSkAAVUrAAFVagABVWwAAVVuAAFVcAABVXMAAVV0AAFVdQABVXYAAVV3AAFVeQABVXsAAVV8AAFVfQABVX8AAVWAAAFVvwABVcEAAVXDAAFVxQABVcgAAVXJAAFVygABVcsAAVXMAAFVzgABVdAAAVXRAAFV0gABVdQAAVXVAAFWFAABVhYAAVYYAAFWGgABVh0AAVYeAAFWHwABViAAAVYhAAFWIwABViUAAVYmAAFWJwABVikAAVYqAAFWaQABVmsAAVZuAAFWcAABVnMAAVZ0AAFWdQABVnYAAVZ3AAFWeQABVnsAAVZ8AAFWfQABVn8AAVaAAAFWvwABVsEAAVbDAAFWxQABVsgAAVbJAAFWygABVssAAVbMAAFWzgABVtAAAVbRAAFW0gABVtQAAVbVAAFXFAABVxYAAVcYAAFXGgABVx0AAVceAAFXHwABVyAAAVchAAFXIwABVyUAAVcmAAFXJwABVykAAVcqAAFXaQABV2sAAVdtAAFXbwABV3IAAVdzAAFXdAABV3UAAVd2AAFXeAABV3oAAVd7AAFXfAABV34AAVd/AAFXygABV+0AAVgNAAFYLQABWC8AAVgxAAFYMwABWDUAAVg4AAFYOQABWDoAAVg9AAFYPgABWEAAAVhBAAFYQwABWEYAAVhHAAFYSAABWEsAAVhMAAFYUQABWF4AAVhjAAFYZQABWGcAAVhsAAFYbwABWHIAAVh0AAFYmQABWL0AAVjkAAFZCAABWQsAAVkNAAFZDwABWREAAVkTAAFZFQABWRYAAVkZAAFZJgABWTcAAVk5AAFZOwABWT0AAVk/AAFZQQABWUMAAVlFAAFZRwABWVgAAVlbAAFZXgABWWEAAVlkAAFZZwABWWoAAVltAAFZcAABWXIAAVmxAAFZswABWbUAAVm3AAFZugABWbsAAVm8AAFZvQABWb4AAVnAAAFZwgABWcMAAVnEAAFZxgABWccAAVoGAAFaCAABWgoAAVoMAAF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wKQABcCwAAXAtAAFwNgABcEMAAXBIAAFwSgABcEwAAXBRAAFwVAABcFcAAXBZAAFwfgABcKIAAXDJAAFw7QABcPAAAXDyAAFw9AABcPYAAXD4AAFw+gABcPsAAXD+AAFxCwABcRwAAXEeAAFxIAABcSIAAXEkAAFxJgABcSgAAXEqAAFxLAABcT0AAXFAAAFxQwABcUYAAXFJAAFxTAABcU8AAXFSAAFxVQABcVcAAXGWAAFxmAABcZoAAXGcAAFxnwABcaAAAXGhAAFxogABcaMAAXGlAAFxpwABcagAAXGpAAFxqwABcawAAXHrAAFx7QABce8AAXHxAAFx9AABcfUAAXH2AAFx9wABcfgAAXH6AAFx/AABcf0AAXH+AAFyAAABcgEAAXJAAAFyQgABckUAAXJHAAFySgABcksAAXJMAAFyTQABck4AAXJQAAFyUgABclMAAXJUAAFyVgABclcAAXJkAAFyZQABcmYAAXJoAAFypwABcqkAAXKrAAFyrQABcrAAAXKxAAFysgABcrMAAXK0AAFytgABcrgAAXK5AAFyugABcrwAAXK9AAFy/AABcv4AAXMAAAFzAgABcwUAAXMGAAFzBwABcwgAAXMJAAFzCwABcw0AAXMOAAFzDwABcxEAAXMSAAFzUQABc1MAAXNVAAFzVwABc1oAAXNbAAFzXAABc10AAXNeAAFzYAABc2IAAXNjAAFzZAABc2YAAXNnAAFzpgABc6gAAXOqAAFzrAABc68AAXOwAAFzsQABc7IAAXOzAAFztQABc7cAAXO4AAFzuQABc7sAAXO8AAFz+wABc/0AAXP/AAF0AQABdAQAAXQFAAF0BgABdAcAAXQIAAF0CgABdAwAAXQNAAF0DgABdBAAAXQRAAF0NgABdFoAAXSBAAF0pQABdKgAAXSqAAF0rAABdK4AAXSwAAF0sgABdLMAAXS2AAF0wwABdNIAAXTUAAF01gABdNgAAXTaAAF03AABdN4AAXTgAAF07wABdPIAAXT1AAF0+AABdPsAAXT+AAF1AQABdQQAAXUGAAF1RQABdUcAAXVKAAF1TAABdU8AAXVQAAF1UQABdVIAAXVTAAF1VQABdVcAAXVYAAF1WQABdVsAAXVcAAF1mwABdZ0AAXWfAAF1oQABdaQAAXWlAAF1pgABdacAAXWoAAF1qgABdawAAXWtAAF1rgABdbAAAXWxAAF18AABdfIAAXX0AAF19gABdfkAAXX6AAF1+wABdfwAAXX9AAF1/wABdgEAAXYCAAF2AwABdgUAAXYGAAF2RQABdkcAAXZKAAF2TAABdk8AAXZQAAF2UQABdlIAAXZTAAF2VQABdlcAAXZYAAF2WQABdlsAAXZcAAF2mwABdp0AAXafAAF2oQABdqQAAXalAAF2pgABdqcAAXaoAAF2qgABdqwAAXatAAF2rgABdrAAAXaxAAF28AABdvIAAXb0AAF29gABdvkAAXb6AAF2+wABdvwAAXb9AAF2/wABdwEAAXcCAAF3AwABdwUAAXcGAAF3RQABd0cAAXdJAAF3SwABd04AAXdPAAF3UAABd1EAAXdSAAF3VAABd1YAAXdXAAF3WAABd1oAAXdbAAF3ZAABd2UAAXdnAAF3qgABd84AAXfyAAF4FQABeDwAAXhcAAF4gwABeKoAAXjKAAF47gABeRIAAXkUAAF5FwABeRkAAXkbAAF5HQABeSAAAXkjAAF5JQABeScAAXkqAAF5LAABeS4AAXkxAAF5NAABeTUAAXk6AAF5RwABeUoAAXlMAAF5TwABeVIAAXlUAAF5eQABeZ0AAXnEAAF56AABeesAAXntAAF57wABefEAAXnzAAF59QABefYAAXn5AAF6BgABehkAAXobAAF6HQABeh8AAXohAAF6IwABeiUAAXonAAF6KQABeisAAXo+AAF6QQABekQAAXpHAAF6SgABek0AAXpQAAF6UwABelYAAXpZAAF6WwABepoAAXqcAAF6nwABeqEAAXqkAAF6pQABeqYAAXqnAAF6qAABeqoAAXqsAAF6rQABeq4AAXqwAAF6sQABeroAAXq7AAF6vQABevwAAXr+AAF7AAABewIAAXsFAAF7BgABewcAAXsIAAF7CQABewsAAXsNAAF7DgABew8AAXsRAAF7EgABe1EAAXtTAAF7VgABe1gAAXtbAAF7XAABe10AAXteAAF7XwABe2EAAXtjAAF7ZAABe2UAAXtnAAF7aAABe3EAAXtyAAF7dAABe7MAAXu1AAF7twABe7kAAXu8AAF7vQABe74AAXu/AAF7wAABe8IAAXvEAAF7xQABe8YAAXvIAAF7yQABfAgAAXwKAAF8DQABfA8AAXwSAAF8EwABfBQAAXwVAAF8FgABfBgAAXwaAAF8GwABfBwAAXweAAF8HwABfCgAAXwpAAF8KwABfGoAAXxsAAF8bgABfHAAAXxzAAF8dAABfHUAAXx2AAF8dwABfHkAAXx7AAF8fAABfH0AAXx/AAF8gAABfL8AAXzBAAF8xAABfMYAAXzJAAF8ygABfMsAAXzMAAF8zQABfM8AAXzRAAF80gABfNMAAXzVAAF81gABfOMAAXzkAAF85QABfOcAAX0mAAF9KAABfSoAAX0sAAF9LwABfTAAAX0xAAF9MgABfTMAAX01AAF9NwABfTgAAX05AAF9OwABfTwAAX17AAF9fQABfYAAAX2CAAF9hQABfYYAAX2HAAF9iAABfYkAAX2LAAF9jQABfY4AAX2PAAF9kQABfZIAAX2cAAF9qQABfboAAX29AAF9wAABfcMAAX3GAAF9yQABfcwAAX3PAAF90gABfeMAAX3mAAF96QABfewAAX3vAAF98gABffUAAX34AAF9+wABff0AAX4QAAF+IgABfi8AAX49AAF+SgABflgAAX5gAAF+ZQABfrAAAX7TAAF+8wABfxMAAX8VAAF/FwABfxkAAX8bAAF/HgABfx8AAX8gAAF/IwABfyQAAX8mAAF/JwABfykAAX8sAAF/LQABfy4AAX8xAAF/MgABfzcAAX9EAAF/SQABf0sAAX9NAAF/UgABf1UAAX9YAAF/WgABf38AAX+jAAF/ygABf+4AAX/xAAF/8wABf/UAAX/3AAF/+QABf/sAAX/8AAF//wABgAwAAYAdAAGAHwABgCEAAYAjAAGAJQABgCcAAYApAAGAKwABgC0AAYA+AAGAQQABgEQAAYBHAAGASgABgE0AAYBQAAGAUwABgFYAAYBYAAGAlwABgJkAAYCbAAGAnQABgKAAAYChAAGAogABgKMAAYCkAAGApgABgKgAAYCpAAGAqgABgKwAAYCtAAGA7AABgO4AAYDwAAGA8gABgPUAAYD2AAGA9wABgPgAAYD5AAGA+wABgP0AAYD+AAGA/wABgQEAAYECAAGBQQABgUMAAYFGAAGBSAABgUsAAYFMAAGBTQABgU4AAYFPAAGBUQABgVMAAYFUAAGBVQABgVcAAYFYAAGBZQABgWYAAYFnAAGBaQABgagAAYGqAAGBrAABga4AAYGxAAGBsgABgbMAAYG0AAGBtQABgbcAAYG5AAGBugABgbsAAYG9AAGBvgABgf0AAYH/AAGCAQABggMAAYIGAAGCBwABgggAAYIJAAGCCgABggwAAYIOAAGCDwABghAAAYISAAGCEwABglIAAYJUAAGCVgABglgAAYJbAAGCXAABgl0AAYJeAAGCXwABgmEAAYJjAAGCZAABgmUAAYJnAAGCaAABgqcAAYKpAAGCqwABgq0AAYKwAAGCsQABgrIAAYKzAAGCtAABgrYAAYK4AAGCuQABgroAAYK8AAGCvQABgvwAAYL+AAGDAAABgwIAAYMFAAGDBgABgwcAAYMIAAGDCQABgwsAAYMNAAGDDgABgw8AAYMRAAGDEgABgzcAAYNbAAGDggABg6YAAYOpAAGDqwABg60AAYOvAAGDsQABg7MAAYO0AAGDtwABg8QAAYPTAAGD1QABg9cAAYPZAAGD2wABg90AAYPfAAGD4QABg/AAAYPzAAGD9gABg/kAAYP8AAGD/wABhAIAAYQFAAGEBwABhEYAAYRIAAGESgABhEwAAYRPAAGEUAABhFEAAYRSAAGEUwABhFUAAYRXAAGEWAABhFkAAYRbAAGEXAABhJsAAYSdAAGEnwABhKEAAYSkAAGEpQABhKYAAYSnAAGEqAABhKoAAYSsAAGErQABhK4AAYSwAAGEsQABhPAAAYTyAAGE9AABhPYAAYT5AAGE+gABhPsAAYT8AAGE/QABhP8AAYUBAAGFAgABhQMAAYUFAAGFBgABhUUAAYVHAAGFSQABhUsAAYVOAAGFTwABhVAAAYVRAAGFUgABhVQAAYVWAAGFVwABhVgAAYVaAAGFWwABhZoAAYWcAAGFngABhaAAAYWjAAGFpAABhaUAAYWmAAGFpwABhakAAYWrAAGFrAABha0AAYWvAAGFsAABhe8AAYXxAAGF8wABhfUAAYX4AAGF+QABhfoAAYX7AAGF/AABhf4AAYYAAAGGAQABhgIAAYYEAAGGBQABhkQAAYZGAAGGSAABhkoAAYZNAAGGTgABhk8AAYZQAAGGUQABhlMAAYZVAAGGVgABhlcAAYZZAAGGWgABhqUAAYbIAAGG6AABhwgAAYcKAAGHDAABhw4AAYcQAAGHEwABhxQAAYcVAAGHGAABhxkAAYcbAAGHHAABhx4AAYchAAGHIgABhyMAAYcmAAGHJwABhywAAYc5AAGHPgABh0AAAYdCAAGHRwABh0oAAYdNAAGHTwABh3QAAYeYAAGHvwABh+MAAYfmAAGH6AABh+oAAYfsAAGH7gABh/AAAYfxAAGH9AABiAEAAYgSAAGIFAABiBYAAYgYAAGIGgABiBwAAYgeAAGIIAABiCIAAYgzAAGINgABiDkAAYg8AAGIPwABiEIAAYhFAAGISAABiEsAAYhNAAGIjAABiI4AAYiQAAGIkgABiJUAAYiWAAGIlwABiJgAAYiZAAGImwABiJ0AAYieAAGInwABiKEAAYiiAAGI4QABiOMAAYjlAAGI5wABiOoAAYjrAAGI7AABiO0AAYjuAAGI8AABiPIAAYjzAAGI9AABiPYAAYj3AAGJNgABiTgAAYk7AAGJPQABiUAAAYlBAAGJQgABiUMAAYlEAAGJRgABiUgAAYlJAAGJSgABiUwAAYlNAAGJWgABiVsAAYlcAAGJXgABiZ0AAYmfAAGJoQABiaMAAYmmAAGJpwABiagAAYmpAAGJqgABiawAAYmuAAGJrwABibAAAYmyAAGJswABifIAAYn0AAGJ9gABifgAAYn7AAGJ/AABif0AAYn+AAGJ/wABigEAAYoDAAGKBAABigUAAYoHAAGKCAABikcAAYpJAAGKSwABik0AAYpQAAGKUQABilIAAYpTAAGKVAABilYAAYpYAAGKWQABiloAAYpcAAGKXQABipwAAYqeAAGKoAABiqIAAYqlAAGKpgABiqcAAYqoAAGKqQABiqsAAYqtAAGKrgABiq8AAYqxAAGKsgABivEAAYrzAAGK9QABivcAAYr6AAGK+wABivwAAYr9AAGK/gABiwAAAYsCAAGLAwABiwQAAYsGAAGLBwABiywAAYtQAAGLdwABi5sAAYueAAGLoAABi6IAAYukAAGLpgABi6gAAYupAAGLrAABi7kAAYvIAAGLygABi8wAAYvOAAGL0AABi9IAAYvUAAGL1gABi+UAAYvoAAGL6wABi+4AAYvxAAGL9AABi/cAAYv6AAGL/AABjDsAAYw9AAGMPwABjEEAAYxEAAGMRQABjEYAAYxHAAGMSAABjEoAAYxMAAGMTQABjE4AAYxQAAGMUQABjJAAAYySAAGMlAABjJYAAYyZAAGMmgABjJsAAYycAAGMnQABjJ8AAYyhAAGMogABjKMAAYylAAGMpgABjOUAAYznAAGM6QABjOsAAYzuAAGM7wABjPAAAYzxAAGM8gABjPQAAYz2AAGM9wABjPgAAYz6AAGM+wABjToAAY08AAGNPgABjUAAAY1DAAGNRAABjUUAAY1GAAGNRwABjUkAAY1LAAGNTAABjU0AAY1PAAGNUAABjY8AAY2RAAGNkwABjZUAAY2YAAGNmQABjZoAAY2bAAGNnAABjZ4AAY2gAAGNoQABjaIAAY2kAAGNpQABjeQAAY3mAAGN6AABjeoAAY3tAAGN7gABje8AAY3wAAGN8QABjfMAAY31AAGN9gABjfcAAY35AAGN+gABjjkAAY47AAGOPQABjj8AAY5CAAGOQwABjkQAAY5FAAGORgABjkgAAY5KAAGOSwABjkwAAY5OAAGOTwABjpoAAY69AAGO3QABjv0AAY7/AAGPAQABjwMAAY8FAAGPCAABjwkAAY8KAAGPDQABjw4AAY8QAAGPEQABjxMAAY8WAAGPFwABjxgAAY8bAAGPHAABjyEAAY8uAAGPMwABjzUAAY83AAGPPAABjz8AAY9CAAGPRAABj2kAAY+NAAGPtAABj9gAAY/bAAGP3QABj98AAY/hAAGP4wABj+UAAY/mAAGP6QABj/YAAZAHAAGQCQABkAsAAZANAAGQDwABkBEAAZATAAGQFQABkBcAAZAoAAGQKwABkC4AAZAxAAGQNAABkDcAAZA6AAGQPQABkEAAAZBCAAGQgQABkIMAAZCFAAGQhwABkIoAAZCLAAGQjAABkI0AAZCOAAGQkAABkJIAAZCTAAGQlAABkJYAAZCXAAGQ1gABkNgAAZDaAAGQ3AABkN8AAZDgAAGQ4QABkOIAAZDjAAGQ5QABkOcAAZDoAAGQ6QABkOsAAZDsAAGRKwABkS0AAZEwAAGRMgABkTUAAZE2AAGRNwABkTgAAZE5AAGROwABkT0AAZE+AAGRPwABkUEAAZFCAAGRTwABkVAAAZFRAAGRUwABkZIAAZGUAAGRlgABkZgAAZGbAAGRnAABkZ0AAZGeAAGRnwABkaEAAZGjAAGRpAABkaUAAZGnAAGRqAABkecAAZHpAAGR6wABke0AAZHwAAGR8QABkfIAAZHzAAGR9AABkfYAAZH4AAGR+QABkfoAAZH8AAGR/QABkjwAAZI+AAGSQAABkkIAAZJFAAGSRgABkkcAAZJIAAGSSQABkksAAZJNAAGSTgABkk8AAZJRAAGSUgABkpEAAZKTAAGSlQABkpcAAZKaAAGSmwABkpwAAZKdAAGSngABkqAAAZKiAAGSowABkqQAAZKmAAGSpwABkuYAAZLoAAGS6gABkuwAAZLvAAGS8AABkvEAAZLyAAGS8wABkvUAAZL3AAGS+AABkvkAAZL7AAGS/AABkyEAAZNFAAGTbAABk5AAAZOTAAGTlQABk5cAAZOZAAGTmwABk50AAZOeAAGToQABk64AAZO9AAGTvwABk8EAAZPDAAGTxQABk8cAAZPJAAGTywABk9oAAZPdAAGT4AABk+MAAZPmAAGT6QABk+wAAZPvAAGT8QABlDAAAZQyAAGUNAABlDYAAZQ5AAGUOgABlDsAAZQ8AAGUPQABlD8AAZRBAAGUQgABlEMAAZRFAAGURgABlIUAAZSHAAGUiQABlIsAAZSOAAGUjwABlJAAAZSRAAGUkgABlJQAAZSWAAGUlwABlJgAAZSaAAGUmwABlNoAAZTcAAGU3gABlOAAAZTjAAGU5AABlOUAAZTmAAGU5wABlOkAAZTrAAGU7AABlO0AAZTvAAGU8AABlS8AAZUxAAGVMwABlTUAAZU4AAGVOQABlToAAZU7AAGVPAABlT4AAZVAAAGVQQABlUIAAZVEAAGVRQABlYQAAZWGAAGViAABlYoAAZWNAAGVjgABlY8AAZWQAAGVkQABlZMAAZWVAAGVlgABlZcAAZWZAAGVmgABldkAAZXbAAGV3QABld8AAZXiAAGV4wABleQAAZXlAAGV5gABlegAAZXqAAGV6wABlewAAZXuAAGV7wABli4AAZYwAAGWMgABljQAAZY3AAGWOAABljkAAZY6AAGWOwABlj0AAZY/AAGWQAABlkEAAZZDAAGWRAABlo8AAZayAAGW0gABlvIAAZb0AAGW9gABlvgAAZb6AAGW/QABlv4AAZb/AAGXAgABlwMAAZcFAAGXBgABlwgAAZcLAAGXDAABlw0AAZcQAAGXEQABlxYAAZcjAAGXKAABlyoAAZcsAAGXMQABlzQAAZc3AAGXOQABl14AAZeCAAGXqQABl80AAZfQAAGX0gABl9QAAZfWAAGX2AABl9oAAZfbAAGX3gABl+sAAZf8AAGX/gABmAAAAZgCAAGYBAABmAYAAZgIAAGYCgABmAwAAZgdAAGYIAABmCMAAZgmAAGYKQABmCwAAZgvAAGYMgABmDUAAZg3AAGYdgABmHgAAZh6AAGYfAABmH8AAZiAAAGYgQABmIIAAZiDAAGYhQABmIcAAZiIAAGYiQABmIsAAZiMAAGYywABmM0AAZjPAAGY0QABmNQAAZjVAAGY1gABmNcAAZjYAAGY2gABmNwAAZjdAAGY3gABmOAAAZjhAAGZIAABmSIAAZklAAGZJwABmSoAAZkrAAGZLAABmS0AAZkuAAGZMAABmTIAAZkzAAGZNAABmTYAAZk3AAGZRAABmUUAAZlGAAGZSAABmYcAAZmJAAGZiwABmY0AAZmQAAGZkQABmZIAAZmTAAGZlAABmZYAAZmYAAGZmQABmZoAAZmcAAGZnQABmdwAAZneAAGZ4AABmeIAAZnlAAGZ5gABmecAAZnoAAGZ6QABmesAAZntAAGZ7gABme8AAZnxAAGZ8gABmjEAAZozAAGaNQABmjcAAZo6AAGaOwABmjwAAZo9AAGaPgABmkAAAZpCAAGaQwABmkQAAZpGAAGaRwABmoYAAZqIAAGaigABmowAAZqPAAGakAABmpEAAZqSAAGakwABmpUAAZqXAAGamAABmpkAAZqbAAGanAABmtsAAZrdAAGa3wABmuEAAZrkAAGa5QABmuYAAZrnAAGa6AABmuoAAZrsAAGa7QABmu4AAZrwAAGa8QABmxYAAZs6AAGbYQABm4UAAZuIAAGbigABm4wAAZuOAAGbkAABm5IAAZuTAAGblgABm6MAAZuyAAGbtAABm7YAAZu4AAGbugABm7wAAZu+AAGbwAABm88AAZvSAAGb1QABm9gAAZvbAAGb3gABm+EAAZvkAAGb5gABnCUAAZwnAAGcKQABnCsAAZwuAAGcLwABnDAAAZwxAAGcMgABnDQAAZw2AAGcNwABnDgAAZw6AAGcOwABnHoAAZx8AAGcfgABnIAAAZyDAAGchAABnIUAAZyGAAGchwABnIkAAZyLAAGcjAABnI0AAZyPAAGckAABnM8AAZzRAAGc0wABnNUAAZzYAAGc2QABnNoAAZzbAAGc3AABnN4AAZzgAAGc4QABnOIAAZzkAAGc5QABnSQAAZ0mAAGdKAABnSoAAZ0tAAGdLgABnS8AAZ0wAAGdMQABnTMAAZ01AAGdNgABnTcAAZ05AAGdOgABnXkAAZ17AAGdfQABnX8AAZ2CAAGdgwABnYQAAZ2FAAGdhgABnYgAAZ2KAAGdiwABnYwAAZ2OAAGdjwABnc4AAZ3QAAGd0gABndQAAZ3XAAGd2AABndkAAZ3aAAGd2wABnd0AAZ3fAAGd4AABneEAAZ3jAAGd5AABniMAAZ4lAAGeJwABnikAAZ4sAAGeLQABni4AAZ4vAAGeMAABnjIAAZ40AAGeNQABnjYAAZ44AAGeOQABnoQAAZ6nAAGexwABnucAAZ7pAAGe6wABnu0AAZ7vAAGe8gABnvMAAZ70AAGe9wABnvgAAZ76AAGe+wABnv0AAZ8AAAGfAQABnwIAAZ8FAAGfBgABnwsAAZ8YAAGfHQABnx8AAZ8hAAGfJgABnykAAZ8sAAGfLgABn1MAAZ93AAGfngABn8IAAZ/FAAGfxwABn8kAAZ/LAAGfzQABn88AAZ/QAAGf0wABn+AAAZ/xAAGf8wABn/UAAZ/3AAGf+QABn/sAAZ/9AAGf/wABoAEAAaASAAGgFQABoBgAAaAbAAGgHgABoCEAAaAkAAGgJwABoCoAAaAsAAGgawABoG0AAaBvAAGgcQABoHQAAaB1AAGgdgABoHcAAaB4AAGgegABoHwAAaB9AAGgfgABoIAAAaCBAAGgwAABoMIAAaDEAAGgxgABoMkAAaDKAAGgywABoMwAAaDNAAGgzwABoNEAAaDSAAGg0wABoNUAAaDWAAGhFQABoRcAAaEaAAGhHAABoR8AAaEgAAGhIQABoSIAAaEjAAGhJQABoScAAaEoAAGhKQABoSsAAaEsAAGhOQABoToAAaE7AAGhPQABoXwAAaF+AAGhgAABoYIAAaGFAAGhhgABoYcAAaGIAAGhiQABoYsAAaGNAAGhjgABoY8AAaGRAAGhkgABodEAAaHTAAGh1QABodcAAaHaAAGh2wABodwAAaHdAAGh3gABoeAAAaHiAAGh4wABoeQAAaHmAAGh5wABoiYAAaIoAAGiKgABoiwAAaIvAAGiMAABojEAAaIyAAGiMwABojUAAaI3AAGiOAABojkAAaI7AAGiPAABonsAAaJ9AAGifwABooEAAaKEAAGihQABooYAAaKHAAGiiAABoooAAaKMAAGijQABoo4AAaKQAAGikQABotAAAaLSAAGi1AABotYAAaLZAAGi2gABotsAAaLcAAGi3QABot8AAaLhAAGi4gABouMAAaLlAAGi5gABowsAAaMvAAGjVgABo3oAAaN9AAGjfwABo4EAAaODAAGjhQABo4cAAaOIAAGjiwABo5gAAaOnAAGjqQABo6sAAaOtAAGjrwABo7EAAaOzAAGjtQABo8QAAaPHAAGjygABo80AAaPQAAGj0wABo9YAAaPZAAGj2wABpBoAAaQcAAGkHgABpCAAAaQjAAGkJAABpCUAAaQmAAGkJwABpCkAAaQrAAGkLAABpC0AAaQvAAGkMAABpG8AAaRxAAGkcwABpHUAAaR4AAGkeQABpHoAAaR7AAGkfAABpH4AAaSAAAGkgQABpIIAAaSEAAGkhQABpMQAAaTGAAGkyAABpMoAAaTNAAGkzgABpM8AAaTQAAGk0QABpNMAAaTVAAGk1gABpNcAAaTZAAGk2gABpRkAAaUbAAGlHQABpR8AAaUiAAGlIwABpSQAAaUlAAGlJgABpSgAAaUqAAGlKwABpSwAAaUuAAGlLwABpW4AAaVwAAGlcgABpXQAAaV3AAGleAABpXkAAaV6AAGlewABpX0AAaV/AAGlgAABpYEAAaWDAAGlhAABpcMAAaXFAAGlxwABpckAAaXMAAGlzQABpc4AAaXPAAGl0AABpdIAAaXUAAGl1QABpdYAAaXYAAGl2QABphgAAaYaAAGmHAABph4AAaYhAAGmIgABpiMAAaYkAAGmJQABpicAAaYpAAGmKgABpisAAaYtAAGmLgABpnkAAaacAAGmvAABptwAAabeAAGm4AABpuIAAabkAAGm5wABpugAAabpAAGm7AABpu0AAabvAAGm8AABpvIAAab1AAGm9gABpvcAAab6AAGm+wABpwAAAacNAAGnEgABpxQAAacWAAGnGwABpx4AAachAAGnIwABp0gAAadsAAGnkwABp7cAAae6AAGnvAABp74AAafAAAGnwgABp8QAAafFAAGnyAABp9UAAafmAAGn6AABp+oAAafsAAGn7gABp/AAAafyAAGn9AABp/YAAagHAAGoCgABqA0AAagQAAGoEwABqBYAAagZAAGoHAABqB8AAaghAAGoYAABqGIAAahkAAGoZgABqGkAAahqAAGoawABqGwAAahtAAGobwABqHEAAahyAAGocwABqHUAAah2AAGotQABqLcAAai5AAGouwABqL4AAai/AAGowAABqMEAAajCAAGoxAABqMYAAajHAAGoyAABqMoAAajLAAGpCgABqQwAAakPAAGpEQABqRQAAakVAAGpFgABqRcAAakYAAGpGgABqRwAAakdAAGpHgABqSAAAakhAAGpLgABqS8AAakwAAGpMgABqXEAAalzAAGpdQABqXcAAal6AAGpewABqXwAAal9AAGpfgABqYAAAamCAAGpgwABqYQAAamGAAGphwABqcYAAanIAAGpygABqcwAAanPAAGp0AABqdEAAanSAAGp0wABqdUAAanXAAGp2AABqdkAAanbAAGp3AABqhsAAaodAAGqHwABqiEAAaokAAGqJQABqiYAAaonAAGqKAABqioAAaosAAGqLQABqi4AAaowAAGqMQABqnAAAapyAAGqdAABqnYAAap5AAGqegABqnsAAap8AAGqfQABqn8AAaqBAAGqggABqoMAAaqFAAGqhgABqsUAAarHAAGqyQABqssAAarOAAGqzwABqtAAAarRAAGq0gABqtQAAarWAAGq1wABqtgAAaraAAGq2wABqwAAAaskAAGrSwABq28AAatyAAGrdAABq3YAAat4AAGregABq3wAAat9AAGrgAABq40AAaucAAGrngABq6AAAauiAAGrpAABq6YAAauoAAGrqgABq7kAAau8AAGrvwABq8IAAavFAAGryAABq8sAAavOAAGr0AABrA8AAawRAAGsEwABrBUAAawYAAGsGQABrBoAAawbAAGsHAABrB4AAawgAAGsIQABrCIAAawkAAGsJQABrGQAAaxmAAGsaAABrGoAAaxtAAGsbgABrG8AAaxwAAGscQABrHMAAax1AAGsdgABrHcAAax5AAGsegABrLkAAay7AAGsvQABrL8AAazCAAGswwABrMQAAazFAAGsxgABrMgAAazKAAGsywABrMwAAazOAAGszwABrQ4AAa0QAAGtEgABrRQAAa0XAAGtGAABrRkAAa0aAAGtGwABrR0AAa0fAAGtIAABrSEAAa0jAAGtJAABrWMAAa1lAAGtZwABrWkAAa1sAAGtbQABrW4AAa1vAAGtcAABrXIAAa10AAGtdQABrXYAAa14AAGteQABrbgAAa26AAGtvAABrb4AAa3BAAGtwgABrcMAAa3EAAGtxQABrccAAa3JAAGtygABrcsAAa3NAAGtzgABrg0AAa4PAAGuEQABrhMAAa4WAAGuFwABrhgAAa4ZAAGuGgABrhwAAa4eAAGuHwABriAAAa4iAAGuIwABrm4AAa6RAAGusQABrtEAAa7TAAGu1QABrtcAAa7ZAAGu3AABrt0AAa7eAAGu4QABruIAAa7kAAGu5QABrucAAa7qAAGu6wABruwAAa7vAAGu8AABrvkAAa8GAAGvCwABrw0AAa8PAAGvFAABrxcAAa8aAAGvHAABr0EAAa9lAAGvjAABr7AAAa+zAAGvtQABr7cAAa+5AAGvuwABr70AAa++AAGvwQABr84AAa/fAAGv4QABr+MAAa/lAAGv5wABr+kAAa/rAAGv7QABr+8AAbAAAAGwAwABsAYAAbAJAAGwDAABsA8AAbASAAGwFQABsBgAAbAaAAGwWQABsFsAAbBdAAGwXwABsGIAAbBjAAGwZAABsGUAAbBmAAGwaAABsGoAAbBrAAGwbAABsG4AAbBvAAGwrgABsLAAAbCyAAGwtAABsLcAAbC4AAGwuQABsLoAAbC7AAGwvQABsL8AAbDAAAGwwQABsMMAAbDEAAGxAwABsQUAAbEIAAGxCgABsQ0AAbEOAAGxDwABsRAAAbERAAGxEwABsRUAAbEWAAGxFwABsRkAAbEaAAGxJwABsSgAAbEpAAGxKwABsWoAAbFsAAGxbgABsXAAAbFzAAGxdAABsXUAAbF2AAGxdwABsXkAAbF7AAGxfAABsX0AAbF/AAGxgAABsb8AAbHBAAGxwwABscUAAbHIAAGxyQABscoAAbHLAAGxzAABsc4AAbHQAAGx0QABsdIAAbHUAAGx1QABshQAAbIWAAGyGAABshoAAbIdAAGyHgABsh8AAbIgAAGyIQABsiMAAbIlAAGyJgABsicAAbIpAAGyKgABsmkAAbJrAAGybQABsm8AAbJyAAGycwABsnQAAbJ1AAGydgABsngAAbJ6AAGyewABsnwAAbJ+AAGyfwABsr4AAbLAAAGywgABssQAAbLHAAGyyAABsskAAbLKAAGyywABss0AAbLPAAGy0AABstEAAbLTAAGy1AABsvkAAbMdAAGzRAABs2gAAbNrAAGzbQABs28AAbNxAAGzcwABs3UAAbN2AAGzeQABs4YAAbOVAAGzlwABs5kAAbObAAGznQABs58AAbOhAAGzowABs7IAAbO1AAGzuAABs7sAAbO+AAGzwQABs8QAAbPHAAGzyQABtAgAAbQKAAG0DAABtA4AAbQRAAG0EgABtBMAAbQUAAG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 + + + + + AppEnvMO + Undefined + 2 + AppEnvMO + 1 + + + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWGNvbnRlbnRz0h8gMjNfEBxOU0tleVBhdGhTcGVjaWZpZXJFeHByZXNzaW9uozIkJdIfIDU2Xk5TTXV0YWJsZUFycmF5ozU3JVdOU0FycmF50h8gOTpfEBNOU0tleVBhdGhFeHByZXNzaW9upDk7JCVfEBROU0Z1bmN0aW9uRXhwcmVzc2lvbgAIABEAGgAkACkAMgA3AEkATABRAFMAYABmAHEAewCKAJ0AqQCwALIAtAC2ALgAugDNANQA3wDhAOMA5QDsAPEA/AEFARwBIAE3AUQBTQFSAV0BXwFhAWMBagF0AXYBeAF6AYMBiAGnAasBsAG/AcMBywHQAeYB6wAAAAAAAAIBAAAAAAAAADwAAAAAAAAAAAAAAAAAAAIC + + imageURLs + + + + user + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWHByZXZpZXdz0h8gMjNfEBxOU0tleVBhdGhTcGVjaWZpZXJFeHByZXNzaW9uozIkJdIfIDU2Xk5TTXV0YWJsZUFycmF5ozU3JVdOU0FycmF50h8gOTpfEBNOU0tleVBhdGhFeHByZXNzaW9upDk7JCVfEBROU0Z1bmN0aW9uRXhwcmVzc2lvbgAIABEAGgAkACkAMgA3AEkATABRAFMAYABmAHEAewCKAJ0AqQCwALIAtAC2ALgAugDNANQA3wDhAOMA5QDsAPEA/AEFARwBIAE3AUQBTQFSAV0BXwFhAWMBagF0AXYBeAF6AYMBiAGnAasBsAG/AcMBywHQAeYB6wAAAAAAAAIBAAAAAAAAADwAAAAAAAAAAAAAAAAAAAIC + + previewURLs + + + + lastOpenDate + + + + quickSearchWords + + + + title + + + + title + + + + sizeCount + + + + YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q +D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIVHRhZ3PSHyAyM18QHE5TS2V5UGF0aFNwZWNpZmllckV4cHJlc3Npb26jMiQl0h8gNTZeTlNNdXRhYmxlQXJyYXmjNTclV05TQXJyYXnSHyA5Ol8QE05TS2V5UGF0aEV4cHJlc3Npb26kOTskJV8QFE5TRnVuY3Rpb25FeHByZXNzaW9uAAgAEQAaACQAKQAyADcASQBMAFEAUwBgAGYAcQB7AIoAnQCpALAAsgC0ALYAuAC6AM0A1ADfAOEA4wDlAOwA8QD8AQUBHAEgATcBRAFNAVIBXQFfAWEBYwFqAXQBdgF4AXoBfwGEAaMBpwGsAbsBvwHHAcwB4gHnAAAAAAAAAgEAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAf4= + + tagStrings + + + + language + + + + uploader + + + + gid + + + + rating + + + + globalFilter + + + + pageCount + + + + tagTranslator + + + + historyKeywords + + + + galleryURL + + + + searchFilter + + + + userRating + + + + tagStrings + + + \ No newline at end of file diff --git a/EhPanda/Database/Migration/MigrationPolicy.swift b/EhPanda/Database/Migration/MigrationPolicy.swift deleted file mode 100644 index 32fa1df0..00000000 --- a/EhPanda/Database/Migration/MigrationPolicy.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// MigrationPolicy.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/07/24. -// - -import CoreData - -struct MigrationUtility { - static func mappingFromString( - previousMO: NSManagedObject, - targetMO: NSManagedObject, - type: T.Type, key: String - ) { - let storedValue = previousMO.value(forKey: key) as? String - let newValue = T(storedValue ?? "") - targetMO.setValue(newValue, forKey: key) - } -} diff --git a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift new file mode 100644 index 00000000..b205ef78 --- /dev/null +++ b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift @@ -0,0 +1,84 @@ +// +// Model5toModel6MigrationPolicy.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/07/24. +// + +import CoreData + +// swiftlint:disable type_name +final class GalleryMO5toGalleryMO6MigrationPolicy: NSEntityMigrationPolicy { + override func createDestinationInstances( + forSource sourceInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager + ) throws { + try super.createDestinationInstances(forSource: sourceInstance, in: mapping, manager: manager) + guard let destinationGalleryMO = manager.destinationInstances( + forEntityMappingName: mapping.name, sourceInstances: [sourceInstance] + ).first else { + throw AppError.databaseCorrupted("Was expected a GalleryMO.") + } + guard let coverURLString = sourceInstance.value(forKey: "coverURL") as? String, + let galleryURLString = sourceInstance.value(forKey: "galleryURL") as? String, + let coverURL = URL(string: coverURLString), let galleryURL = URL(string: galleryURLString) + else { throw AppError.databaseCorrupted("Failed in resolving coverURL, galleryURL.") } + destinationGalleryMO.setValue(coverURL, forKey: "coverURL") + destinationGalleryMO.setValue(galleryURL, forKey: "galleryURL") + } +} + +final class GalleryDetailMO5toGalleryDetailMO6MigrationPolicy: NSEntityMigrationPolicy { + override func createDestinationInstances( + forSource sourceInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager + ) throws { + try super.createDestinationInstances(forSource: sourceInstance, in: mapping, manager: manager) + guard let destinationGalleryDetailMO = manager.destinationInstances( + forEntityMappingName: mapping.name, sourceInstances: [sourceInstance] + ).first else { + throw AppError.databaseCorrupted("Was expected a GalleryDetailMO.") + } + let parentURLString = sourceInstance.value(forKey: "parentURL") as? String + let archiveURLString = sourceInstance.value(forKey: "archiveURL") as? String + guard let coverURLString = sourceInstance.value(forKey: "coverURL") as? String, + let coverURL = URL(string: coverURLString) + else { throw AppError.databaseCorrupted("Failed in resolving coverURL.") } + destinationGalleryDetailMO.setValue(URL(string: parentURLString ?? ""), forKey: "parentURL") + destinationGalleryDetailMO.setValue(URL(string: archiveURLString ?? ""), forKey: "archiveURL") + destinationGalleryDetailMO.setValue(coverURL, forKey: "coverURL") + } +} + +final class GalleryStateMO5toGalleryStateMO6MigrationPolicy: NSEntityMigrationPolicy { + override func createDestinationInstances( + forSource sourceInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager + ) throws { + try super.createDestinationInstances(forSource: sourceInstance, in: mapping, manager: manager) + guard let destinationGalleryStateMO = manager.destinationInstances( + forEntityMappingName: mapping.name, sourceInstances: [sourceInstance] + ).first else { + throw AppError.databaseCorrupted("Was expected a GalleryStateMO.") + } + let previews = sourceInstance.value(forKey: "previews") as? [Int: String] + let thumbnails = sourceInstance.value(forKey: "thumbnails") as? [Int: String] + let contents = sourceInstance.value(forKey: "contents") as? [Int: String] + let originalContents = sourceInstance.value(forKey: "originalContents") as? [Int: String] + destinationGalleryStateMO.setValue(previews?.mapToURLs, forKey: "previewURLs") + destinationGalleryStateMO.setValue(thumbnails?.mapToURLs, forKey: "thumbnailURLs") + destinationGalleryStateMO.setValue(contents?.mapToURLs, forKey: "imageURLs") + destinationGalleryStateMO.setValue(originalContents?.mapToURLs, forKey: "originalImageURLs") + } +} +// swiftlint:enable type_name + +private extension Dictionary where Value == String { + func mapToURLs() -> [Key: URL] { + compactMap { (key, value) -> (Key, URL)? in + if let url = URL(string: value) { + return (key, url) + } else { + return nil + } + } + .reduce(into: [:]) { $0[$1.0] = $1.1 } + } +} diff --git a/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion b/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion index fe638601..afc544a1 100644 --- a/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion +++ b/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model 5.xcdatamodel + Model 6.xcdatamodel diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents new file mode 100644 index 00000000..94e26030 --- /dev/null +++ b/EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/EhPanda/Database/Persistence.swift b/EhPanda/Database/Persistence.swift index 7842ef8d..c38fb452 100644 --- a/EhPanda/Database/Persistence.swift +++ b/EhPanda/Database/Persistence.swift @@ -9,153 +9,85 @@ import CoreData struct PersistenceController { static let shared = PersistenceController() + let migrator = CoreDataMigrator() let container: NSPersistentCloudKitContainer = { let container = NSPersistentCloudKitContainer(name: "Model") - - container.loadPersistentStores { - guard let error = $1 else { return } - Logger.error(error as Any) - } + let description = container.persistentStoreDescriptions.first + description?.shouldInferMappingModelAutomatically = false + description?.shouldMigrateStoreAutomatically = false return container }() +} - static func prepareForPreviews() { - PersistenceController.add(galleries: [Gallery.preview]) - PersistenceController.add(detail: GalleryDetail.preview) - PersistenceController.update(fetchedState: GalleryState.preview) +// MARK: Preparation +extension PersistenceController { + func prepare(completion: @escaping (Result) -> Void) { + do { + try loadPersistentStore(completion: completion) + } catch { + completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) + } } - static func saveContext() { - let context = shared.container.viewContext - AppUtil.dispatchMainSync { - guard context.hasChanges else { return } + func rebuild(completion: @escaping (Result) -> Void) { + guard let storeURL = container.persistentStoreDescriptions.first?.url else { + completion(.failure(.databaseCorrupted("PersistentContainer was not set up properly."))) + return + } + DispatchQueue.global().async { do { - try context.save() + try NSPersistentStoreCoordinator.destroyStore(at: storeURL) } catch { - Logger.error(error) - fatalError("Unresolved error \(error)") + completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) } - } - } - - static func checkExistence( - entityType: MO.Type, predicate: NSPredicate - ) -> Bool { - fetch(entityType: entityType, predicate: predicate) != nil - } - - static func materializedObjects( - in context: NSManagedObjectContext, matching predicate: NSPredicate - ) -> [NSManagedObject] { - var objects = [NSManagedObject]() - for object in context.registeredObjects where !object.isFault { - guard object.entity.attributesByName.keys.contains("gid"), - predicate.evaluate(with: object) - else { continue } - objects.append(object) - } - return objects - } - - static func fetch( - entityType: MO.Type, predicate: NSPredicate? = nil, - findBeforeFetch: Bool = true, commitChanges: ((MO?) -> Void)? = nil - ) -> MO? { - let managedObject = batchFetch( - entityType: entityType, fetchLimit: 1, - predicate: predicate, findBeforeFetch: findBeforeFetch - ).first - commitChanges?(managedObject) - return managedObject - } - - static func batchFetch( - entityType: MO.Type, fetchLimit: Int = 0, predicate: NSPredicate? = nil, - findBeforeFetch: Bool = true, sortDescriptors: [NSSortDescriptor]? = nil - ) -> [MO] { - var results = [MO]() - let context = shared.container.viewContext - AppUtil.dispatchMainSync { - if findBeforeFetch, let predicate = predicate { - if let objects = materializedObjects( - in: context, matching: predicate - ) as? [MO], !objects.isEmpty { - results = objects + container.loadPersistentStores { _, error in + guard error == nil else { + let message = "Was unable to load store \(String(describing: error))." + completion(.failure(.databaseCorrupted(message))) return } + completion(.success(())) } - let request = NSFetchRequest( - entityName: String(describing: entityType) - ) - request.predicate = predicate - request.fetchLimit = fetchLimit - request.sortDescriptors = sortDescriptors - results = (try? context.fetch(request)) ?? [] } - return results } - - static func fetchOrCreate( - entityType: MO.Type, predicate: NSPredicate? = nil, - commitChanges: ((MO?) -> Void)? = nil - ) -> MO { - if let storedMO = fetch( - entityType: entityType, predicate: predicate, commitChanges: commitChanges - ) { - return storedMO - } else { - let newMO = MO(context: shared.container.viewContext) - commitChanges?(newMO) - saveContext() - return newMO + private func loadPersistentStore(completion: @escaping (Result) -> Void) throws { + try migrateStoreIfNeeded { result in + switch result { + case .success: + container.loadPersistentStores { _, error in + guard error == nil else { + let message = "Was unable to load store \(String(describing: error))." + completion(.failure(.databaseCorrupted(message))) + return + } + completion(.success(())) + } + case .failure(let error): + completion(.failure(error)) + } } } - - static func update( - entityType: MO.Type, predicate: NSPredicate? = nil, - createIfNil: Bool = false, commitChanges: (MO) -> Void - ) { - let storedMO: MO? - if createIfNil { - storedMO = fetchOrCreate(entityType: entityType, predicate: predicate) - } else { - storedMO = fetch(entityType: entityType, predicate: predicate) + private func migrateStoreIfNeeded(completion: @escaping (Result) -> Void) throws { + guard let storeURL = container.persistentStoreDescriptions.first?.url else { + throw AppError.databaseCorrupted("PersistentContainer was not set up properly.") } - if let storedMO = storedMO { - commitChanges(storedMO) - saveContext() - } - } - - static func batchUpdate( - entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ([MO]) -> Void - ) { - let storedMOs = batchFetch(entityType: entityType, predicate: predicate, findBeforeFetch: false) - commitChanges(storedMOs) - saveContext() - } - static func update( - entityType: MO.Type, gid: String, - createIfNil: Bool = false, - commitChanges: @escaping ((MO) -> Void) - ) { - AppUtil.dispatchMainSync { - let storedMO: MO? - if createIfNil { - storedMO = fetchOrCreate(entityType: entityType, gid: gid) - } else { - storedMO = fetch(entityType: entityType, gid: gid) - } - if let storedMO = storedMO { - commitChanges(storedMO) - saveContext() + if try migrator.requiresMigration(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) { + DispatchQueue.global().async { + do { + try migrator.migrateStore(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) + } catch { + completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) + } + completion(.success(())) } + } else { + completion(.success(())) } } } -// MARK: Protocol Definition +// MARK: Definition protocol ManagedObjectProtocol { associatedtype Entity func toEntity() -> Entity diff --git a/EhPanda/Database/PersistenceAccessor.swift b/EhPanda/Database/PersistenceAccessor.swift deleted file mode 100644 index ab9c042d..00000000 --- a/EhPanda/Database/PersistenceAccessor.swift +++ /dev/null @@ -1,236 +0,0 @@ -// -// PersistenceAccessor.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/07/05. -// - -import SwiftUI -import CoreData - -protocol PersistenceAccessor { - var gid: String { get } -} - -extension PersistenceAccessor { - var gallery: Gallery { - PersistenceController.fetchGalleryNonNil(gid: gid) - } - var galleryDetail: GalleryDetail? { - PersistenceController.fetchGalleryDetail(gid: gid) - } - var galleryState: GalleryState { - PersistenceController.fetchGalleryStateNonNil(gid: gid) - } -} - -// MARK: Accessor Method -extension PersistenceController { - static func fetchGallery(gid: String) -> Gallery? { - var entity: Gallery? - AppUtil.dispatchMainSync { - entity = fetch(entityType: GalleryMO.self, gid: gid)?.toEntity() - } - return entity.forceUnwrapped - } - static func fetchGalleryNonNil(gid: String) -> Gallery { - fetchGallery(gid: gid) ?? Gallery.preview - } - static func fetchGalleryDetail(gid: String) -> GalleryDetail? { - var entity: GalleryDetail? - AppUtil.dispatchMainSync { - entity = fetch(entityType: GalleryDetailMO.self, gid: gid)?.toEntity() - } - return entity - } - static func fetchGalleryStateNonNil(gid: String) -> GalleryState { - var entity: GalleryState? - AppUtil.dispatchMainSync { - entity = fetchOrCreate(entityType: GalleryStateMO.self, gid: gid).toEntity() - } - return entity.forceUnwrapped - } - static func fetchAppEnvNonNil() -> AppEnv { - var entity: AppEnv? - AppUtil.dispatchMainSync { - entity = fetchOrCreate(entityType: AppEnvMO.self).toEntity() - } - return entity.forceUnwrapped - } - static func fetchGalleryHistory() -> [Gallery] { - let predicate = NSPredicate(format: "lastOpenDate != nil") - let sortDescriptor = NSSortDescriptor( - keyPath: \GalleryMO.lastOpenDate, ascending: false - ) - return batchFetch( - entityType: GalleryMO.self, predicate: predicate, - findBeforeFetch: false, sortDescriptors: [sortDescriptor] - ).map({ $0.toEntity() }) - } - static func clearGalleryHistory() { - let predicate = NSPredicate(format: "lastOpenDate != nil") - batchUpdate(entityType: GalleryMO.self, predicate: predicate) { galleryMOs in - galleryMOs.forEach { galleryMO in - galleryMO.lastOpenDate = nil - } - } - } - - static func fetch( - entityType: MO.Type, gid: String, - findBeforeFetch: Bool = true, - commitChanges: ((MO?) -> Void)? = nil - ) -> MO? { - fetch( - entityType: entityType, predicate: NSPredicate(format: "gid == %@", gid), - findBeforeFetch: findBeforeFetch, commitChanges: commitChanges - ) - } - static func fetchOrCreate(entityType: MO.Type, gid: String) -> MO { - fetchOrCreate( - entityType: entityType, predicate: NSPredicate(format: "gid == %@", gid) - ) { managedObject in - managedObject?.gid = gid - } - } - - static func add(galleries: [Gallery]) { - for gallery in galleries { - let storedMO = fetch(entityType: GalleryMO.self, gid: gallery.gid) { managedObject in - managedObject?.category = gallery.category.rawValue - managedObject?.coverURL = gallery.coverURL - managedObject?.galleryURL = gallery.galleryURL - if let language = gallery.language { - managedObject?.language = language.rawValue - } - // managedObject?.lastOpenDate = gallery.lastOpenDate - managedObject?.pageCount = Int64(gallery.pageCount) - managedObject?.postedDate = gallery.postedDate - managedObject?.rating = gallery.rating - managedObject?.title = gallery.title - managedObject?.token = gallery.token - if let uploader = gallery.uploader { - managedObject?.uploader = uploader - } - } - if storedMO == nil { - gallery.toManagedObject(in: shared.container.viewContext) - } - } - saveContext() - } - - static func add(detail: GalleryDetail) { - let storedMO = fetch(entityType: GalleryDetailMO.self, gid: detail.gid) { managedObject in - managedObject?.archiveURL = detail.archiveURL - managedObject?.category = detail.category.rawValue - managedObject?.coverURL = detail.coverURL - managedObject?.isFavored = detail.isFavored - managedObject?.visibility = detail.visibility.toData() - managedObject?.jpnTitle = detail.jpnTitle - managedObject?.language = detail.language.rawValue - managedObject?.favoredCount = Int64(detail.favoredCount) - managedObject?.pageCount = Int64(detail.pageCount) - managedObject?.parentURL = detail.parentURL - managedObject?.postedDate = detail.postedDate - managedObject?.rating = detail.rating - managedObject?.userRating = detail.userRating - managedObject?.ratingCount = Int64(detail.ratingCount) - managedObject?.sizeCount = detail.sizeCount - managedObject?.sizeType = detail.sizeType - managedObject?.title = detail.title - managedObject?.torrentCount = Int64(detail.torrentCount) - managedObject?.uploader = detail.uploader - } - if storedMO == nil { - detail.toManagedObject(in: shared.container.viewContext) - } - saveContext() - } - - static func galleryCached(gid: String) -> Bool { - PersistenceController.checkExistence( - entityType: GalleryMO.self, predicate: NSPredicate(format: "gid == %@", gid) - ) - } - - static func updateLastOpenDate(gid: String) { - update(entityType: GalleryMO.self, gid: gid) { galleryMO in - galleryMO.lastOpenDate = Date() - } - } - static func update(appEnvMO: (AppEnvMO) -> Void) { - update(entityType: AppEnvMO.self, createIfNil: true, commitChanges: appEnvMO) - } - - // MARK: GalleryState - static func removeImageURLs() { - batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in - galleryStateMOs.forEach { galleryStateMO in - galleryStateMO.contents = nil - galleryStateMO.previews = nil - galleryStateMO.thumbnails = nil - } - } - } - static func update(gid: String, galleryStateMO: @escaping ((GalleryStateMO) -> Void)) { - update(entityType: GalleryStateMO.self, gid: gid, createIfNil: true, commitChanges: galleryStateMO) - } - static func update(gid: String, readingProgress: Int) { - update(gid: gid) { galleryStateMO in - galleryStateMO.readingProgress = Int64(readingProgress) - } - } - static func update(gid: String, thumbnails: [Int: String]) { - update(gid: gid) { galleryStateMO in - guard !thumbnails.isEmpty else { return } - if let storedThumbnails = galleryStateMO.thumbnails?.toObject() as [Int: String]? { - galleryStateMO.thumbnails = storedThumbnails.merging( - thumbnails, uniquingKeysWith: { _, new in new } - ).toData() - } else { - galleryStateMO.thumbnails = thumbnails.toData() - } - } - } - static func update(gid: String, contents: [Int: String], originalContents: [Int: String]) { - update(gid: gid) { galleryStateMO in - guard !contents.isEmpty else { return } - update(gid: gid, storedData: &galleryStateMO.contents, new: contents) - guard !originalContents.isEmpty else { return } - update(gid: gid, storedData: &galleryStateMO.originalContents, new: originalContents) - } - } - private static func update( - gid: String, storedData: inout Data?, new: [Int: T] - ) { - guard !new.isEmpty else { return } - - if let storedDictionary = storedData?.toObject() as [Int: T]? { - storedData = storedDictionary.merging( - new, uniquingKeysWith: { _, new in new } - ).toData() - } else { - storedData = new.toData() - } - } - static func update(fetchedState: GalleryState) { - update(gid: fetchedState.gid) { galleryStateMO in - if !fetchedState.tags.isEmpty { - galleryStateMO.tags = fetchedState.tags.toData() - } - if !fetchedState.comments.isEmpty { - galleryStateMO.comments = fetchedState.comments.toData() - } - if !fetchedState.previews.isEmpty { - if let storedPreviews = galleryStateMO.previews?.toObject() as [Int: String]? { - galleryStateMO.previews = storedPreviews.merging( - fetchedState.previews, uniquingKeysWith: { stored, _ in stored } - ).toData() - } else { - galleryStateMO.previews = fetchedState.previews.toData() - } - } - } - } -} diff --git a/EhPanda/Models/EhSetting.swift b/EhPanda/Models/EhSetting.swift deleted file mode 100644 index 0f9b6488..00000000 --- a/EhPanda/Models/EhSetting.swift +++ /dev/null @@ -1,453 +0,0 @@ -// -// EhSetting.swift -// EhSetting -// -// Created by 荒木辰造 on R 3/08/08. -// - -// MARK: EhSetting -struct EhSetting: Equatable { - static let categoryNames = Category.allFiltersCases.map(\.rawValue).map { value in - value.lowercased().replacingOccurrences(of: " ", with: "") - } - static let languageValues = [ - 1024, 2048, 1, 1025, 2049, 10, 1034, 2058, - 20, 1044, 2068, 30, 1054, 2078, 40, 1064, 2088, - 50, 1074, 2098, 60, 1084, 2108, 70, 1094, 2118, - 80, 1104, 2128, 90, 1114, 2138, 100, 1124, 2148, - 110, 1134, 2158, 120, 1144, 2168, 130, 1154, 2178, - 254, 1278, 2302, 255, 1279, 2303 - ] - - let ehProfiles: [EhProfile] - - var capableLoadThroughHathSetting: EhSettingLoadThroughHathSetting - var capableImageResolution: EhSettingImageResolution - var capableSearchResultCount: EhSettingSearchResultCount - var capableThumbnailConfigSize: EhSettingThumbnailSize - var capableThumbnailConfigRows: EhSettingThumbnailRows - - var loadThroughHathSetting: EhSettingLoadThroughHathSetting - var browsingCountry: EhSettingBrowsingCountry - let literalBrowsingCountry: String - var imageResolution: EhSettingImageResolution - var imageSizeWidth: Float - var imageSizeHeight: Float - var galleryName: EhSettingGalleryName - var archiverBehavior: EhSettingArchiverBehavior - var displayMode: EhSettingDisplayMode - var disabledCategories: [Bool] - var favoriteNames: [String] - var favoritesSortOrder: EhSettingFavoritesSortOrder - var ratingsColor: String - var excludedNamespaces: [Bool] - var tagFilteringThreshold: Float - var tagWatchingThreshold: Float - var excludedLanguages: [Bool] - var excludedUploaders: String - var searchResultCount: EhSettingSearchResultCount - var thumbnailLoadTiming: EhSettingThumbnailLoadTiming - var thumbnailConfigSize: EhSettingThumbnailSize - var thumbnailConfigRows: EhSettingThumbnailRows - var thumbnailScaleFactor: Float - var viewportVirtualWidth: Float - var commentsSortOrder: EhSettingCommentsSortOrder - var commentVotesShowTiming: EhSettingCommentVotesShowTiming - var tagsSortOrder: EhSettingTagsSortOrder - var galleryShowPageNumbers: Bool - var hathLocalNetworkHost: String - var useOriginalImages: Bool? - var useMultiplePageViewer: Bool? - var multiplePageViewerStyle: EhSettingMultiplePageViewerStyle? - var multiplePageViewerShowThumbnailPane: Bool? -} - -// MARK: EhProfile -struct EhProfile: Comparable, Identifiable, Hashable { - static func < (lhs: EhProfile, rhs: EhProfile) -> Bool { - lhs.value < rhs.value - } - var id: Int { value } - - let value: Int - let name: String - let isSelected: Bool - var isDefault: Bool { - value == 1 - } -} -enum EhProfileAction: String { - case create - case delete - case rename - case `default` -} - -// MARK: LoadThroughHathSetting -enum EhSettingLoadThroughHathSetting: Int, CaseIterable, Identifiable, Comparable { - case anyClient - case defaultPortOnly - case no -} -extension EhSettingLoadThroughHathSetting { - var id: Int { rawValue } - static func < ( - lhs: EhSettingLoadThroughHathSetting, - rhs: EhSettingLoadThroughHathSetting - ) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .anyClient: - return "Any client" - case .defaultPortOnly: - return "Default port clients only" - case .no: - return "LOAD_THROUGH_HATH_NO" - } - } - var description: String { - switch self { - case .anyClient: - return "Recommended." - case .defaultPortOnly: - return "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports." - case .no: - return "Donator only. You will not be able to browse as many pages, enable only if having severe problems." - } - } -} - -// MARK: ImageResolution -enum EhSettingImageResolution: Int, CaseIterable, Identifiable, Comparable { - case auto - case x780 - case x980 - case x1280 - case x1600 - case x2400 -} -extension EhSettingImageResolution { - var id: Int { rawValue } - static func < ( - lhs: EhSettingImageResolution, - rhs: EhSettingImageResolution - ) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .auto: - return "Auto" - case .x780: - return "780x" - case .x980: - return "980x" - case .x1280: - return "1280x" - case .x1600: - return "1600x" - case .x2400: - return "2400x" - } - } -} - -// MARK: GalleryName -enum EhSettingGalleryName: Int, CaseIterable, Identifiable { - case `default` - case japanese -} -extension EhSettingGalleryName { - var id: Int { rawValue } - - var value: String { - switch self { - case .default: - return "Default Title" - case .japanese: - return "Japanese Title (if available)" - } - } -} - -// MARK: ArchiverBehavior -enum EhSettingArchiverBehavior: Int, CaseIterable, Identifiable { - case manualSelectManualStart - case manualSelectAutoStart - case autoSelectOriginalManualStart - case autoSelectOriginalAutoStart - case autoSelectResampleManualStart - case autoSelectResampleAutoStart -} -extension EhSettingArchiverBehavior { - var id: Int { rawValue } - - var value: String { - switch self { - case .manualSelectManualStart: - return "Manual Select, Manual Start (Default)" - case .manualSelectAutoStart: - return "Manual Select, Auto Start" - case .autoSelectOriginalManualStart: - return "Auto Select Original, Manual Start" - case .autoSelectOriginalAutoStart: - return "Auto Select Original, Auto Start" - case .autoSelectResampleManualStart: - return "Auto Select Resample, Manual Start" - case .autoSelectResampleAutoStart: - return "Auto Select Resample, Auto Start" - } - } -} - -// MARK: DisplayMode -enum EhSettingDisplayMode: Int, CaseIterable, Identifiable { - case compact - case thumbnail - case extended - case minimal - case minimalPlus -} -extension EhSettingDisplayMode { - var id: Int { rawValue } - - var value: String { - switch self { - case .compact: - return "Compact" - case .thumbnail: - return "Thumbnail" - case .extended: - return "Extended" - case .minimal: - return "Minimal" - case .minimalPlus: - return "Minimal+" - } - } -} - -// MARK: FavoritesSortOrder -enum EhSettingFavoritesSortOrder: Int, CaseIterable, Identifiable { - case lastUpdateTime - case favoritedTime -} -extension EhSettingFavoritesSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .lastUpdateTime: - return "By last gallery update time" - case .favoritedTime: - return "By favorited time" - } - } -} - -// MARK: SearchResultCount -enum EhSettingSearchResultCount: Int, CaseIterable, Identifiable, Comparable { - case twentyFive - case fifty - case oneHundred - case twoHundred -} -extension EhSettingSearchResultCount { - var id: Int { rawValue } - static func < ( - lhs: EhSettingSearchResultCount, - rhs: EhSettingSearchResultCount - ) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .twentyFive: - return "25" - case .fifty: - return "50" - case .oneHundred: - return "100" - case .twoHundred: - return "200" - } - } -} - -// MARK: ThumbnailLoadTiming -enum EhSettingThumbnailLoadTiming: Int, CaseIterable, Identifiable { - case onMouseOver - case onPageLoad -} -extension EhSettingThumbnailLoadTiming { - var id: Int { rawValue } - - var value: String { - switch self { - case .onMouseOver: - return "On mouse-over" - case .onPageLoad: - return "On page load" - } - } - var description: String { - switch self { - case .onMouseOver: - return "Pages load faster, but there may be a slight delay before a thumb appears." - case .onPageLoad: - return "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded." - } - } -} - -// MARK: ThumbnailSize -enum EhSettingThumbnailSize: Int, CaseIterable, Identifiable, Comparable { - case normal - case large -} -extension EhSettingThumbnailSize { - var id: Int { rawValue } - static func < ( - lhs: EhSettingThumbnailSize, - rhs: EhSettingThumbnailSize - ) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .normal: - return "Normal" - case .large: - return "Large" - } - } -} - -// MARK: ThumbnailRows -enum EhSettingThumbnailRows: Int, CaseIterable, Identifiable, Comparable { - case four - case ten - case twenty - case forty -} -extension EhSettingThumbnailRows { - var id: Int { rawValue } - static func < ( - lhs: EhSettingThumbnailRows, - rhs: EhSettingThumbnailRows - ) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .four: - return "4" - case .ten: - return "10" - case .twenty: - return "20" - case .forty: - return "40" - } - } -} - -// MARK: CommentsSortOrder -enum EhSettingCommentsSortOrder: Int, CaseIterable, Identifiable { - case oldest - case recent - case highestScore -} -extension EhSettingCommentsSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .oldest: - return "Oldest comments first" - case .recent: - return "Recent comments first" - case .highestScore: - return "By highest score" - } - } -} - -// MARK: CommentVotesShowTiming -enum EhSettingCommentVotesShowTiming: Int, CaseIterable, Identifiable { - case onHoverOrClick - case always -} -extension EhSettingCommentVotesShowTiming { - var id: Int { rawValue } - - var value: String { - switch self { - case .onHoverOrClick: - return "On score hover or click" - case .always: - return "Always" - } - } -} - -// MARK: TagsSortOrder -enum EhSettingTagsSortOrder: Int, CaseIterable, Identifiable { - case alphabetical - case tagPower -} -extension EhSettingTagsSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .alphabetical: - return "Alphabetical" - case .tagPower: - return "By tag power" - } - } -} - -// MARK: MultiplePageViewerStyle -enum EhSettingMultiplePageViewerStyle: Int, CaseIterable, Identifiable { - case alignLeftScaleIfOverWidth - case alignCenterScaleIfOverWidth - case alignCenterAlwaysScale -} -extension EhSettingMultiplePageViewerStyle { - var id: Int { rawValue } - - var value: String { - switch self { - case .alignLeftScaleIfOverWidth: - return "Align left, scale if overwidth" - case .alignCenterScaleIfOverWidth: - return "Align center, scale if overwidth" - case .alignCenterAlwaysScale: - return "Align center, always scale" - } - } -} - -// MARK: EhSettingBrowsingCountry -// swiftlint:disable line_length switch_case_alignment -enum EhSettingBrowsingCountry: String, CaseIterable, Identifiable, Equatable { - case autoDetect = "-"; case afghanistan = "AF"; case alandIslands = "AX"; case albania = "AL"; case algeria = "DZ"; case americanSamoa = "AS"; case andorra = "AD"; case angola = "AO"; case anguilla = "AI"; case antarctica = "AQ"; case antiguaandBarbuda = "AG"; case argentina = "AR"; case armenia = "AM"; case aruba = "AW"; case asiaPacificRegion = "AP"; case australia = "AU"; case austria = "AT"; case azerbaijan = "AZ"; case bahamas = "BS"; case bahrain = "BH"; case bangladesh = "BD"; case barbados = "BB"; case belarus = "BY"; case belgium = "BE"; case belize = "BZ"; case benin = "BJ"; case bermuda = "BM"; case bhutan = "BT"; case bolivia = "BO"; case bonaireSaintEustatiusandSaba = "BQ"; case bosniaandHerzegovina = "BA"; case botswana = "BW"; case bouvetIsland = "BV"; case brazil = "BR"; case britishIndianOceanTerritory = "IO"; case bruneiDarussalam = "BN"; case bulgaria = "BG"; case burkinaFaso = "BF"; case burundi = "BI"; case cambodia = "KH"; case cameroon = "CM"; case canada = "CA"; case capeVerde = "CV"; case caymanIslands = "KY"; case centralAfricanRepublic = "CF"; case chad = "TD"; case chile = "CL"; case china = "CN"; case christmasIsland = "CX"; case cocosIslands = "CC"; case colombia = "CO"; case comoros = "KM"; case congo = "CG"; case congoTheDemocraticRepublicofthe = "CD"; case cookIslands = "CK"; case costaRica = "CR"; case coteDIvoire = "CI"; case croatia = "HR"; case cuba = "CU"; case curacao = "CW"; case cyprus = "CY"; case czechRepublic = "CZ"; case denmark = "DK"; case djibouti = "DJ"; case dominica = "DM"; case dominicanRepublic = "DO"; case ecuador = "EC"; case egypt = "EG"; case elSalvador = "SV"; case equatorialGuinea = "GQ"; case eritrea = "ER"; case estonia = "EE"; case ethiopia = "ET"; case europe = "EU"; case falklandIslands = "FK"; case faroeIslands = "FO"; case fiji = "FJ"; case finland = "FI"; case france = "FR"; case frenchGuiana = "GF"; case frenchPolynesia = "PF"; case frenchSouthernTerritories = "TF"; case gabon = "GA"; case gambia = "GM"; case georgia = "GE"; case germany = "DE"; case ghana = "GH"; case gibraltar = "GI"; case greece = "GR"; case greenland = "GL"; case grenada = "GD"; case guadeloupe = "GP"; case guam = "GU"; case guatemala = "GT"; case guernsey = "GG"; case guinea = "GN"; case guineaBissau = "GW"; case guyana = "GY"; case haiti = "HT"; case heardIslandandMcDonaldIslands = "HM"; case holySeeVaticanCityState = "VA"; case honduras = "HN"; case hongKong = "HK"; case hungary = "HU"; case iceland = "IS"; case india = "IN"; case indonesia = "ID"; case iran = "IR"; case iraq = "IQ"; case ireland = "IE"; case isleofMan = "IM"; case israel = "IL"; case italy = "IT"; case jamaica = "JM"; case japan = "JP"; case jersey = "JE"; case jordan = "JO"; case kazakhstan = "KZ"; case kenya = "KE"; case kiribati = "KI"; case kuwait = "KW"; case kyrgyzstan = "KG"; case laoPeoplesDemocraticRepublic = "LA"; case latvia = "LV"; case lebanon = "LB"; case lesotho = "LS"; case liberia = "LR"; case libya = "LY"; case liechtenstein = "LI"; case lithuania = "LT"; case luxembourg = "LU"; case macau = "MO"; case macedonia = "MK"; case madagascar = "MG"; case malawi = "MW"; case malaysia = "MY"; case maldives = "MV"; case mali = "ML"; case malta = "MT"; case marshallIslands = "MH"; case martinique = "MQ"; case mauritania = "MR"; case mauritius = "MU"; case mayotte = "YT"; case mexico = "MX"; case micronesia = "FM"; case moldova = "MD"; case monaco = "MC"; case mongolia = "MN"; case montenegro = "ME"; case montserrat = "MS"; case morocco = "MA"; case mozambique = "MZ"; case myanmar = "MM"; case namibia = "NA"; case nauru = "NR"; case nepal = "NP"; case netherlands = "NL"; case newCaledonia = "NC"; case newZealand = "NZ"; case nicaragua = "NI"; case niger = "NE"; case nigeria = "NG"; case niue = "NU"; case norfolkIsland = "NF"; case northKorea = "KP"; case northernMarianaIslands = "MP"; case norway = "NO"; case oman = "OM"; case pakistan = "PK"; case palau = "PW"; case palestinianTerritory = "PS"; case panama = "PA"; case papuaNewGuinea = "PG"; case paraguay = "PY"; case peru = "PE"; case philippines = "PH"; case pitcairnIslands = "PN"; case poland = "PL"; case portugal = "PT"; case puertoRico = "PR"; case qatar = "QA"; case reunion = "RE"; case romania = "RO"; case russianFederation = "RU"; case rwanda = "RW"; case saintBarthelemy = "BL"; case saintHelena = "SH"; case saintKittsandNevis = "KN"; case saintLucia = "LC"; case saintMartin = "MF"; case saintPierreandMiquelon = "PM"; case saintVincentandtheGrenadines = "VC"; case samoa = "WS"; case sanMarino = "SM"; case saoTomeandPrincipe = "ST"; case saudiArabia = "SA"; case senegal = "SN"; case serbia = "RS"; case seychelles = "SC"; case sierraLeone = "SL"; case singapore = "SG"; case sintMaarten = "SX"; case slovakia = "SK"; case slovenia = "SI"; case solomonIslands = "SB"; case somalia = "SO"; case southAfrica = "ZA"; case southGeorgiaandtheSouthSandwichIslands = "GS"; case southKorea = "KR"; case southSudan = "SS"; case spain = "ES"; case sriLanka = "LK"; case sudan = "SD"; case suriname = "SR"; case svalbardandJanMayen = "SJ"; case swaziland = "SZ"; case sweden = "SE"; case switzerland = "CH"; case syrianArabRepublic = "SY"; case taiwan = "TW"; case tajikistan = "TJ"; case tanzania = "TZ"; case thailand = "TH"; case timorLeste = "TL"; case togo = "TG"; case tokelau = "TK"; case tonga = "TO"; case trinidadandTobago = "TT"; case tunisia = "TN"; case turkey = "TR"; case turkmenistan = "TM"; case turksandCaicosIslands = "TC"; case tuvalu = "TV"; case uganda = "UG"; case ukraine = "UA"; case unitedArabEmirates = "AE"; case unitedKingdom = "GB"; case unitedStates = "US"; case unitedStatesMinorOutlyingIslands = "UM"; case uruguay = "UY"; case uzbekistan = "UZ"; case vanuatu = "VU"; case venezuela = "VE"; case vietnam = "VN"; case virginIslandsBritish = "VG"; case virginIslandsUS = "VI"; case wallisandFutuna = "WF"; case westernSahara = "EH"; case yemen = "YE"; case zambia = "ZM"; case zimbabwe = "ZW" -} -extension EhSettingBrowsingCountry { - var id: Int { hashValue } - var name: 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 .congoTheDemocraticRepublicofthe: 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 .holySeeVaticanCityState: 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 switch_case_alignment diff --git a/EhPanda/Models/Filter.swift b/EhPanda/Models/Filter.swift deleted file mode 100644 index 12a3210e..00000000 --- a/EhPanda/Models/Filter.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// Filter.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/08. -// - -import SwiftUI -import BetterCodable - -struct Filter: Codable { - @DefaultFalse var doujinshi = false - @DefaultFalse var manga = false - @DefaultFalse var artistCG = false - @DefaultFalse var gameCG = false - @DefaultFalse var western = false - @DefaultFalse var nonH = false - @DefaultFalse var imageSet = false - @DefaultFalse var cosplay = false - @DefaultFalse var asianPorn = false - @DefaultFalse var misc = false - - @DefaultFalse var advanced = false - @DefaultTrue var galleryName = true - @DefaultTrue var galleryTags = true - @DefaultFalse var galleryDesc = false - @DefaultFalse var torrentFilenames = false - @DefaultFalse var onlyWithTorrents = false - @DefaultFalse var lowPowerTags = false { - didSet { - if lowPowerTags { - downvotedTags = false - } - } - } - @DefaultFalse var downvotedTags = false { - didSet { - if downvotedTags { - lowPowerTags = false - } - } - } - @DefaultFalse var expungedGalleries = false - - @DefaultFalse var minRatingActivated = false - @DefaultIntegerValue var minRating = 2 - - @DefaultFalse var pageRangeActivated = false - @DefaultStringValue var pageLowerBound = "" { - didSet { - if Int(pageLowerBound) == nil && !pageLowerBound.isEmpty { - pageLowerBound = "" - } - } - } - @DefaultStringValue var pageUpperBound = "" { - didSet { - if Int(pageUpperBound) == nil && !pageUpperBound.isEmpty { - pageUpperBound = "" - } - } - } - - @DefaultFalse var disableLanguage = false - @DefaultFalse var disableUploader = false - @DefaultFalse var disableTags = false -} diff --git a/EhPanda/Models/Gallery/Category.swift b/EhPanda/Models/Gallery/Category.swift new file mode 100644 index 00000000..8af0e15a --- /dev/null +++ b/EhPanda/Models/Gallery/Category.swift @@ -0,0 +1,87 @@ +// +// Category.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/01. +// + +import SwiftUI + +enum Category: String, Codable, CaseIterable, Identifiable { + var id: String { rawValue } + + static let allFavoritesCases: [Self] = [.misc] + allCases.dropLast(2) + static let allFiltersCases: [Self] = allCases.dropLast() + + case doujinshi = "Doujinshi" + case manga = "Manga" + case artistCG = "Artist CG" + case gameCG = "Game CG" + case western = "Western" + case nonH = "Non-H" + case imageSet = "Image Set" + case cosplay = "Cosplay" + case asianPorn = "Asian Porn" + case misc = "Misc" + case `private` = "Private" +} + +extension Category { + var color: Color { + .init(AppUtil.galleryHost.rawValue + "/" + rawValue) + } + var filterValue: Int { + switch self { + case .doujinshi: + return 2 + case .manga: + return 4 + case .artistCG: + return 8 + case .gameCG: + return 16 + case .western: + return 512 + case .nonH: + return 256 + case .imageSet: + return 32 + case .cosplay: + return 64 + case .asianPorn: + return 128 + case .misc: + return 1 + case .private: + let message = "Category `Private` shouldn't be used in filters!" + Logger.error(message) + fatalError(message) + } + } + var value: String { + switch self { + case .doujinshi: + return R.string.localizable.enumCategoryValueDoujinshi() + case .manga: + return R.string.localizable.enumCategoryValueManga() + case .artistCG: + return R.string.localizable.enumCategoryValueArtistCG() + case .gameCG: + return R.string.localizable.enumCategoryValueGameCG() + case .western: + return R.string.localizable.enumCategoryValueWestern() + case .nonH: + return R.string.localizable.enumCategoryValueNonH() + case .imageSet: + return R.string.localizable.enumCategoryValueImageSet() + case .cosplay: + return R.string.localizable.enumCategoryValueCosplay() + case .asianPorn: + return R.string.localizable.enumCategoryValueAsianPorn() + case .misc: + return R.string.localizable.enumCategoryValueMisc() + case .private: + return R.string.localizable.enumCategoryValuePrivate() + } + } +} diff --git a/EhPanda/Models/Gallery/Gallery.swift b/EhPanda/Models/Gallery/Gallery.swift new file mode 100644 index 00000000..ca5aaa99 --- /dev/null +++ b/EhPanda/Models/Gallery/Gallery.swift @@ -0,0 +1,97 @@ +// +// Gallery.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/01. +// + +import SwiftUI + +struct Gallery: Identifiable, Codable, Equatable, Hashable { + static func == (lhs: Gallery, rhs: Gallery) -> Bool { + lhs.gid == rhs.gid + } + + static func mockGalleries(count: Int, randomID: Bool = true) -> [Gallery] { + guard randomID, count > 0 else { + return Array(repeating: .empty, count: count) + } + return (0...count).map { _ in .empty } + } + static var empty: Gallery { + .init( + gid: UUID().uuidString, + token: "", + title: "", + rating: 0.0, + tagStrings: [], + category: .doujinshi, + language: .japanese, + uploader: "", + pageCount: 1, + postedDate: .now, + coverURL: nil, + galleryURL: nil + ) + } + static let preview = Gallery( + gid: UUID().uuidString, + token: "", + title: "Preview", + rating: 3.5, + tagStrings: [], + category: .doujinshi, + language: .japanese, + uploader: "Anonymous", + pageCount: 1, + postedDate: .now, + coverURL: URL( + string: "https://github.com/" + + "tatsuz0u/Imageset/blob/" + + "main/JPGs/2.jpg?raw=true" + ), + galleryURL: nil + ) + + var trimmedTitle: String { + var title = title + if let range = title.range(of: "|") { + title = String(title[.. Int { + index / batchSize + } + func batchRange(index: Int) -> ClosedRange { + let lowerBound = pageNumber(index: index) * batchSize + 1 + let upperBound = lowerBound + batchSize - 1 + return lowerBound...upperBound + } +} diff --git a/EhPanda/Models/Gallery/GalleryTorrent.swift b/EhPanda/Models/Gallery/GalleryTorrent.swift new file mode 100644 index 00000000..15c66e77 --- /dev/null +++ b/EhPanda/Models/Gallery/GalleryTorrent.swift @@ -0,0 +1,30 @@ +// +// GalleryTorrent.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/01. +// + +import Foundation + +struct GalleryTorrent: Identifiable, Codable, Equatable { + var id: UUID = .init() + let postedDate: Date + let fileSize: String + let seedCount: Int + let peerCount: Int + let downloadCount: Int + let uploader: String + let fileName: String + let hash: String + let torrentURL: URL +} + +extension GalleryTorrent: DateFormattable { + var originalDate: Date { + postedDate + } + var magnetURL: String { + "magnet:?xt=urn:btih:\(hash)" + } +} diff --git a/EhPanda/Models/Gallery/Language.swift b/EhPanda/Models/Gallery/Language.swift new file mode 100644 index 00000000..115c3179 --- /dev/null +++ b/EhPanda/Models/Gallery/Language.swift @@ -0,0 +1,162 @@ +// +// Language.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/30. +// + +enum Language: String, Codable { + static let allExcludedCases: [Self] = [ + .japanese, .english, .chinese, .dutch, .french, .german, .hungarian, .italian, + .korean, .polish, .portuguese, .russian, .spanish, .thai, .vietnamese, .invalid, .other + ] + // swiftlint:disable line_length + case invalid = "N/A"; case other = "Other"; case afrikaans = "Afrikaans"; case albanian = "Albanian"; case arabic = "Arabic"; case bengali = "Bengali"; case bosnian = "Bosnian"; case bulgarian = "Bulgarian"; case burmese = "Burmese"; case catalan = "Catalan"; case cebuano = "Cebuano"; case chinese = "Chinese"; case croatian = "Croatian"; case czech = "Czech"; case danish = "Danish"; case dutch = "Dutch"; case english = "English"; case esperanto = "Esperanto"; case estonian = "Estonian"; case finnish = "Finnish"; case french = "French"; case georgian = "Georgian"; case german = "German"; case greek = "Greek"; case hebrew = "Hebrew"; case hindi = "Hindi"; case hmong = "Hmong"; case hungarian = "Hungarian"; case indonesian = "Indonesian"; case italian = "Italian"; case japanese = "Japanese"; case kazakh = "Kazakh"; case khmer = "Khmer"; case korean = "Korean"; case kurdish = "Kurdish"; case lao = "Lao"; case latin = "Latin"; case mongolian = "Mongolian"; case ndebele = "Ndebele"; case nepali = "Nepali"; case norwegian = "Norwegian"; case oromo = "Oromo"; case pashto = "Pashto"; case persian = "Persian"; case polish = "Polish"; case portuguese = "Portuguese"; case punjabi = "Punjabi"; case romanian = "Romanian"; case russian = "Russian"; case sango = "Sango"; case serbian = "Serbian"; case shona = "Shona"; case slovak = "Slovak"; case slovenian = "Slovenian"; case somali = "Somali"; case spanish = "Spanish"; case swahili = "Swahili"; case swedish = "Swedish"; case tagalog = "Tagalog"; case thai = "Thai"; case tigrinya = "Tigrinya"; case turkish = "Turkish"; case ukrainian = "Ukrainian"; case urdu = "Urdu"; case vietnamese = "Vietnamese"; case zulu = "Zulu" + // swiftlint:enable line_length +} + +extension Language { + var abbreviation: String { + switch self { + // swiftlint:disable switch_case_alignment line_length + case .invalid, .other: return "N/A"; case .afrikaans: return "AF"; case .albanian: return "SQ"; case .arabic: return "AR"; case .bengali: return "BN"; case .bosnian: return "BS"; case .bulgarian: return "BG"; case .burmese: return "MY"; case .catalan: return "CA"; case .cebuano: return "CEB"; case .chinese: return "ZH"; case .croatian: return "HR"; case .czech: return "CS"; case .danish: return "DA"; case .dutch: return "NL"; case .english: return "EN"; case .esperanto: return "EO"; case .estonian: return "ET"; case .finnish: return "FI"; case .french: return "FR"; case .georgian: return "KA"; case .german: return "DE"; case .greek: return "EL"; case .hebrew: return "HE"; case .hindi: return "HI"; case .hmong: return "HMN"; case .hungarian: return "HU"; case .indonesian: return "ID"; case .italian: return "IT"; case .japanese: return "JA"; case .kazakh: return "KK"; case .khmer: return "KM"; case .korean: return "KO"; case .kurdish: return "KU"; case .lao: return "LO"; case .latin: return "LA"; case .mongolian: return "MN"; case .ndebele: return "ND"; case .nepali: return "NE"; case .norwegian: return "NO"; case .oromo: return "OM"; case .pashto: return "PS"; case .persian: return "FA"; case .polish: return "PL"; case .portuguese: return "PT"; case .punjabi: return "PA"; case .romanian: return "RO"; case .russian: return "RU"; case .sango: return "SG"; case .serbian: return "SR"; case .shona: return "SN"; case .slovak: return "SK"; case .slovenian: return "SL"; case .somali: return "SO"; case .spanish: return "ES"; case .swahili: return "SW"; case .swedish: return "SV"; case .tagalog: return "TL"; case .thai: return "TH"; case .tigrinya: return "TI"; case .turkish: return "TR"; case .ukrainian: return "UK"; case .urdu: return "UR"; case .vietnamese: return "VI"; case .zulu: return "ZU" + // swiftlint:enable switch_case_alignment line_length + } + } + var value: String { + switch self { + case .invalid: + return R.string.localizable.enumLanguageValueInvalid() + case .other: + return R.string.localizable.enumLanguageValueOther() + case .afrikaans: + return R.string.localizable.enumLanguageValueAfrikaans() + case .albanian: + return R.string.localizable.enumLanguageValueAlbanian() + case .arabic: + return R.string.localizable.enumLanguageValueArabic() + case .bengali: + return R.string.localizable.enumLanguageValueBengali() + case .bosnian: + return R.string.localizable.enumLanguageValueBosnian() + case .bulgarian: + return R.string.localizable.enumLanguageValueBulgarian() + case .burmese: + return R.string.localizable.enumLanguageValueBurmese() + case .catalan: + return R.string.localizable.enumLanguageValueCatalan() + case .cebuano: + return R.string.localizable.enumLanguageValueCebuano() + case .chinese: + return R.string.localizable.enumLanguageValueChinese() + case .croatian: + return R.string.localizable.enumLanguageValueCroatian() + case .czech: + return R.string.localizable.enumLanguageValueCzech() + case .danish: + return R.string.localizable.enumLanguageValueDanish() + case .dutch: + return R.string.localizable.enumLanguageValueDutch() + case .english: + return R.string.localizable.enumLanguageValueEnglish() + case .esperanto: + return R.string.localizable.enumLanguageValueEsperanto() + case .estonian: + return R.string.localizable.enumLanguageValueEstonian() + case .finnish: + return R.string.localizable.enumLanguageValueFinnish() + case .french: + return R.string.localizable.enumLanguageValueFrench() + case .georgian: + return R.string.localizable.enumLanguageValueGeorgian() + case .german: + return R.string.localizable.enumLanguageValueGerman() + case .greek: + return R.string.localizable.enumLanguageValueGreek() + case .hebrew: + return R.string.localizable.enumLanguageValueHebrew() + case .hindi: + return R.string.localizable.enumLanguageValueHindi() + case .hmong: + return R.string.localizable.enumLanguageValueHmong() + case .hungarian: + return R.string.localizable.enumLanguageValueHungarian() + case .indonesian: + return R.string.localizable.enumLanguageValueIndonesian() + case .italian: + return R.string.localizable.enumLanguageValueItalian() + case .japanese: + return R.string.localizable.enumLanguageValueJapanese() + case .kazakh: + return R.string.localizable.enumLanguageValueKazakh() + case .khmer: + return R.string.localizable.enumLanguageValueKhmer() + case .korean: + return R.string.localizable.enumLanguageValueKorean() + case .kurdish: + return R.string.localizable.enumLanguageValueKurdish() + case .lao: + return R.string.localizable.enumLanguageValueLao() + case .latin: + return R.string.localizable.enumLanguageValueLatin() + case .mongolian: + return R.string.localizable.enumLanguageValueMongolian() + case .ndebele: + return R.string.localizable.enumLanguageValueNdebele() + case .nepali: + return R.string.localizable.enumLanguageValueNepali() + case .norwegian: + return R.string.localizable.enumLanguageValueNorwegian() + case .oromo: + return R.string.localizable.enumLanguageValueOromo() + case .pashto: + return R.string.localizable.enumLanguageValuePashto() + case .persian: + return R.string.localizable.enumLanguageValuePersian() + case .polish: + return R.string.localizable.enumLanguageValuePolish() + case .portuguese: + return R.string.localizable.enumLanguageValuePortuguese() + case .punjabi: + return R.string.localizable.enumLanguageValuePunjabi() + case .romanian: + return R.string.localizable.enumLanguageValueRomanian() + case .russian: + return R.string.localizable.enumLanguageValueRussian() + case .sango: + return R.string.localizable.enumLanguageValueSango() + case .serbian: + return R.string.localizable.enumLanguageValueSerbian() + case .shona: + return R.string.localizable.enumLanguageValueShona() + case .slovak: + return R.string.localizable.enumLanguageValueSlovak() + case .slovenian: + return R.string.localizable.enumLanguageValueSlovenian() + case .somali: + return R.string.localizable.enumLanguageValueSomali() + case .spanish: + return R.string.localizable.enumLanguageValueSpanish() + case .swahili: + return R.string.localizable.enumLanguageValueSwahili() + case .swedish: + return R.string.localizable.enumLanguageValueSwedish() + case .tagalog: + return R.string.localizable.enumLanguageValueTagalog() + case .thai: + return R.string.localizable.enumLanguageValueThai() + case .tigrinya: + return R.string.localizable.enumLanguageValueTigrinya() + case .turkish: + return R.string.localizable.enumLanguageValueTurkish() + case .ukrainian: + return R.string.localizable.enumLanguageValueUkrainian() + case .urdu: + return R.string.localizable.enumLanguageValueUrdu() + case .vietnamese: + return R.string.localizable.enumLanguageValueVietnamese() + case .zulu: + return R.string.localizable.enumLanguageValueZulu() + } + } +} diff --git a/EhPanda/Models/Gallery/TagCategory.swift b/EhPanda/Models/Gallery/TagCategory.swift new file mode 100644 index 00000000..82e1002d --- /dev/null +++ b/EhPanda/Models/Gallery/TagCategory.swift @@ -0,0 +1,52 @@ +// +// TagCategory.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/01. +// + +enum TagCategory: String, Codable, CaseIterable { + case reclass + case language + case parody + case character + case group + case artist + case male + case female + case mixed + case cosplayer + case other + case temp +} + +extension TagCategory { + var value: String { + switch self { + case .reclass: + return R.string.localizable.enumTagCategoryValueReclass() + case .language: + return R.string.localizable.enumTagCategoryValueLanguage() + case .parody: + return R.string.localizable.enumTagCategoryValueParody() + case .character: + return R.string.localizable.enumTagCategoryValueCharacter() + case .group: + return R.string.localizable.enumTagCategoryValueGroup() + case .artist: + return R.string.localizable.enumTagCategoryValueArtist() + case .male: + return R.string.localizable.enumTagCategoryValueMale() + case .female: + return R.string.localizable.enumTagCategoryValueFemale() + case .mixed: + return R.string.localizable.enumTagCategoryValueMixed() + case .cosplayer: + return R.string.localizable.enumTagCategoryValueCosplayer() + case .other: + return R.string.localizable.enumTagCategoryValueOther() + case .temp: + return R.string.localizable.enumTagCategoryValueTemp() + } + } +} diff --git a/EhPanda/Models/Misc.swift b/EhPanda/Models/Misc.swift deleted file mode 100644 index 04a96bc7..00000000 --- a/EhPanda/Models/Misc.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// Misc.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/15. -// - -import Foundation -import SwiftyBeaver - -typealias Percentage = Int -typealias Keyword = String -typealias Identity = String -typealias APIKey = String -typealias CurrentGP = String -typealias CurrentCredits = String -typealias ReloadToken = Any -typealias Logger = SwiftyBeaver -typealias FavoritesSortOrder = EhSettingFavoritesSortOrder - -struct PageNumber: Equatable { - var current = 0 - var maximum = 0 - - var isSinglePage: Bool { - current == 0 && maximum == 0 - } -} - -struct Greeting: Codable, Equatable { - var gainedEXP: Int? - var gainedCredits: Int? - var gainedGP: Int? - var gainedHath: Int? - var updateTime: Date? - - var strings: [String] { - var strings = [String]() - - if let exp = gainedEXP { - strings.append("\(exp) EXP") - } - if let credits = gainedCredits { - strings.append("\(credits) Credits") - } - if let galleryPoint = gainedGP { - strings.append("\(galleryPoint) GP") - } - if let hath = gainedHath { - strings.append("\(hath) Hath") - } - - return strings - } - - var gainContent: String? { - guard !strings.isEmpty else { return nil } - - var base = "GAINCONTENT_START".localized - - if strings.count == 1 { - base += strings[0] - } else { - let stringsToJoin = strings.count > 2 - ? strings.dropLast() : strings - - base += stringsToJoin - .joined( - separator: - "GAINCONTENT_SEPARATOR" - .localized - ) - if strings.count > 2 { - base += "GAINCONTENT_AND".localized - base += strings[strings.count - 1] - } - } - - base += "GAINCONTENT_END".localized - - return base - } - - var gainedNothing: Bool { - [ - gainedEXP, - gainedCredits, - gainedGP, - gainedHath - ] - .compactMap({ $0 }) - .isEmpty - } -} - -struct QuickSearchWord: Codable, Equatable, Identifiable { - var id = UUID().uuidString - var alias: String? - let content: String -} diff --git a/EhPanda/Models/Models.swift b/EhPanda/Models/Models.swift deleted file mode 100644 index 7f56887b..00000000 --- a/EhPanda/Models/Models.swift +++ /dev/null @@ -1,485 +0,0 @@ -// -// Models.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/11/22. -// - -import SwiftUI - -// MARK: Protocols -protocol DateFormattable { - var originalDate: Date { get } -} -extension DateFormattable { - var formattedDateString: String { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - formatter.locale = Locale.current - formatter.calendar = Calendar.current - return formatter.string(from: originalDate) - } -} - -// MARK: Structs -struct Gallery: Identifiable, Codable, Equatable { - static func == (lhs: Gallery, rhs: Gallery) -> Bool { - lhs.gid == rhs.gid - } - - static let preview = Gallery( - gid: "", - token: "", - title: "Preview", - rating: 3.5, - tags: [], - category: .doujinshi, - language: .japanese, - uploader: "Anonymous", - pageCount: 0, - postedDate: .now, - coverURL: "https://github.com/" - + "tatsuz0u/Imageset/blob/" - + "main/JPGs/2.jpg?raw=true", - galleryURL: "" - ) - - var id: String { gid } - let gid: String - let token: String - - var title: String - var rating: Float - var tags: [String] - let category: Category - var language: Language? - let uploader: String? - var pageCount: Int - let postedDate: Date - let coverURL: String - let galleryURL: String - var lastOpenDate: Date? -} - -struct GalleryDetail: Codable { - static let preview = GalleryDetail( - gid: "", - title: "Preview", - jpnTitle: "プレビュー", - isFavored: true, - visibility: .yes, - rating: 3.5, - userRating: 4.0, - ratingCount: 1919, - category: .doujinshi, - language: .japanese, - uploader: "Anonymous", - postedDate: .distantPast, - coverURL: "https://github.com/" - + "tatsuz0u/Imageset/blob/" - + "main/JPGs/2.jpg?raw=true", - favoredCount: 514, - pageCount: 114, - sizeCount: 514, - sizeType: "MB", - torrentCount: 101 - ) - - let gid: String - var title: String - var jpnTitle: String? - var isFavored: Bool - var visibility: GalleryVisibility - var rating: Float - var userRating: Float - var ratingCount: Int - let category: Category - let language: Language - let uploader: String - let postedDate: Date - let coverURL: String - var archiveURL: String? - var parentURL: String? - var favoredCount: Int - var pageCount: Int - var sizeCount: Float - var sizeType: String - var torrentCount: Int -} - -struct GalleryState: Codable { - static let empty = GalleryState(gid: "") - static let preview = GalleryState(gid: "") - - let gid: String - var tags = [GalleryTag]() - var readingProgress = 0 - var previews = [Int: String]() - var previewConfig: PreviewConfig? - var comments = [GalleryComment]() - var contents = [Int: String]() - var originalContents = [Int: String]() - var thumbnails = [Int: String]() -} - -struct GalleryArchive: Codable { - struct HathArchive: Codable, Identifiable { - var id: String { resolution.rawValue } - - let resolution: ArchiveRes - let fileSize: String - let gpPrice: String - } - - let hathArchives: [HathArchive] -} - -struct GalleryTag: Codable, Identifiable { - var id: String { namespace } - - let namespace: String - let content: [String] - let category: TagCategory? - - init(namespace: String = "other", content: [String]) { - self.namespace = namespace - self.content = content - self.category = TagCategory(rawValue: namespace) - } -} - -struct GalleryComment: Identifiable, Codable { - var id: String { commentID } - - var votedUp: Bool - var votedDown: Bool - let votable: Bool - let editable: Bool - - let score: String? - let author: String - let contents: [CommentContent] - let commentID: String - let commentDate: Date -} - -struct CommentContent: Identifiable, Codable { - var id: String { - [ - "\(type.rawValue)", - text, link, imgURL, - secondLink, secondImgURL - ] - .compactMap({$0}).joined() - } - - let type: CommentContentType - var text: String? - var link: String? - var imgURL: String? - - var secondLink: String? - var secondImgURL: String? -} - -struct GalleryTorrent: Identifiable, Codable { - var id: String { uploader + formattedDateString } - - let postedDate: Date - let fileSize: String - let seedCount: Int - let peerCount: Int - let downloadCount: Int - let uploader: String - let fileName: String - let hash: String - let torrentURL: String -} - -struct Log: Identifiable, Comparable { - static func < (lhs: Log, rhs: Log) -> Bool { - lhs.fileName < rhs.fileName - } - - var id: String { fileName } - let fileName: String - let contents: [String] -} - -// MARK: Computed Properties -extension Gallery: DateFormattable, CustomStringConvertible { - var description: String { - "Gallery(\(gid))" - } - - var filledCount: Int { Int(rating) } - var halfFilledCount: Int { Int(rating - 0.5) == filledCount ? 1 : 0 } - var notFilledCount: Int { 5 - filledCount - halfFilledCount } - - var color: Color { - category.color - } - var originalDate: Date { - postedDate - } -} - -extension GalleryDetail: DateFormattable, CustomStringConvertible { - var description: String { - "GalleryDetail(gid: \(gid), \(jpnTitle ?? title))" - } - - var languageAbbr: String { - language.abbreviation - } - var originalDate: Date { - postedDate - } -} - -extension GalleryState: CustomStringConvertible { - var description: String { - "GalleryState(gid: \(gid), tags: \(tags.count), " - + "previews: \(previews.count), comments: \(comments.count))" - } -} - -extension GalleryComment: DateFormattable { - var originalDate: Date { - commentDate - } -} - -extension GalleryTorrent: DateFormattable, CustomStringConvertible { - var description: String { - "GalleryTorrent(\(fileName))" - } - var originalDate: Date { - postedDate - } - var magnetURL: String { - Defaults.URL.magnet(hash: hash) - } -} - -extension Category { - var color: Color { - Color(AppUtil.galleryHost.rawValue + "/" + rawValue) - } - var value: Int { - switch self { - case .doujinshi: - return 2 - case .manga: - return 4 - case .artistCG: - return 8 - case .gameCG: - return 16 - case .western: - return 512 - case .nonH: - return 256 - case .imageSet: - return 32 - case .cosplay: - return 64 - case .asianPorn: - return 128 - case .misc: - return 1 - case .private: - let message = "Category `Private` shouldn't be used in filters!" - Logger.error(message) - fatalError(message) - } - } -} - -extension Language { - var name: String { - switch self { - case .other: - return "LANGUAGE_OTHER" - case .invalid: - return "LANGUAGE_INVALID" - default: - return rawValue - } - } - var abbreviation: String { - switch self { - // swiftlint:disable switch_case_alignment line_length - case .invalid: return "N/A" case .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 - } - } -} - -// MARK: Enums -enum Category: String, Codable, CaseIterable, Identifiable { - var id: String { rawValue } - - static let allFavoritesCases: [Category] = [.misc] + allCases.dropLast(2) - static let allFiltersCases: [Category] = allCases.dropLast() - - case doujinshi = "Doujinshi" - case manga = "Manga" - case artistCG = "Artist CG" - case gameCG = "Game CG" - case western = "Western" - case nonH = "Non-H" - case imageSet = "Image Set" - case cosplay = "Cosplay" - case asianPorn = "Asian Porn" - case misc = "Misc" - case `private` = "Private" -} - -enum TagCategory: String, Codable, CaseIterable { - case reclass - case language - case parody - case character - case group - case artist - case male - case female - case mixed - case cosplayer - case other - case temp -} - -enum GalleryVisibility: Codable, Equatable { - case yes - case no(reason: String) -} - -extension GalleryVisibility { - var value: String { - switch self { - case .yes: - return "Yes" - case .no(let reason): - return "No".localized - + " (\(reason.localized))" - } - } -} - -enum ArchiveRes: String, Codable, CaseIterable { - case x780 = "780x" - case x980 = "980x" - case x1280 = "1280x" - case x1600 = "1600x" - case x2400 = "2400x" - case original = "Original" -} - -extension ArchiveRes { - var name: String { - switch self { - case .x780, .x980, .x1280, .x1600, .x2400: - return rawValue - case .original: - return "ARCHIVE_RESOLUTION_ORIGINAL" - } - } - var param: String { - switch self { - case .original: - return "org" - default: - return String(rawValue.dropLast()) - } - } -} - -enum CommentContentType: Int, Codable { - case singleImg - case doubleImg - case linkedImg - case doubleLinkedImg - - case plainText - case linkedText - - case singleLink -} - -enum PreviewConfig: Codable, Equatable { - case normal(rows: Int) - case large(rows: Int) -} - -extension PreviewConfig { - var batchSize: Int { - switch self { - case .normal(let rows): - return 10 * rows - case .large(let rows): - return 5 * rows - } - } - - func pageNumber(index: Int) -> Int { - index / batchSize - } - func batchRange(index: Int) -> ClosedRange { - let lowerBound = pageNumber(index: index) * batchSize + 1 - let upperBound = lowerBound + batchSize - 1 - return lowerBound...upperBound - } -} - -enum TranslatableLanguage: Codable, CaseIterable { - case japanese - case simplifiedChinese - case traditionalChinese -} - -extension TranslatableLanguage { - var languageCode: String { - switch self { - case .japanese: - return "ja" - case .simplifiedChinese: - return "zh-Hans" - case .traditionalChinese: - return "zh-Hant" - } - } - var repoName: String { - switch self { - case .japanese: - return Defaults.URL.ehTagTranslationJpnRepo - case .simplifiedChinese, - .traditionalChinese: - return Defaults.URL.ehTagTrasnlationRepo - } - } - var remoteFilename: String { - switch self { - case .japanese: - return "jpn_text.json" - case .simplifiedChinese, .traditionalChinese: - return "db.text.json" - } - } - var checkUpdateLink: String { - Defaults.URL.githubAPI( - repoName: repoName - ) - } - var downloadLink: String { - Defaults.URL.githubDownload(repoName: repoName, fileName: remoteFilename) - } -} - -enum Language: String, Codable { - // swiftlint:disable line_length - case invalid = "N/A"; case other = "Other"; case afrikaans = "Afrikaans"; case albanian = "Albanian"; case arabic = "Arabic"; case bengali = "Bengali"; case bosnian = "Bosnian"; case bulgarian = "Bulgarian"; case burmese = "Burmese"; case catalan = "Catalan"; case cebuano = "Cebuano"; case chinese = "Chinese"; case croatian = "Croatian"; case czech = "Czech"; case danish = "Danish"; case dutch = "Dutch"; case english = "English"; case esperanto = "Esperanto"; case estonian = "Estonian"; case finnish = "Finnish"; case french = "French"; case georgian = "Georgian"; case german = "German"; case greek = "Greek"; case hebrew = "Hebrew"; case hindi = "Hindi"; case hmong = "Hmong"; case hungarian = "Hungarian"; case indonesian = "Indonesian"; case italian = "Italian"; case japanese = "Japanese"; case kazakh = "Kazakh"; case khmer = "Khmer"; case korean = "Korean"; case kurdish = "Kurdish"; case lao = "Lao"; case latin = "Latin"; case mongolian = "Mongolian"; case ndebele = "Ndebele"; case nepali = "Nepali"; case norwegian = "Norwegian"; case oromo = "Oromo"; case pashto = "Pashto"; case persian = "Persian"; case polish = "Polish"; case portuguese = "Portuguese"; case punjabi = "Punjabi"; case romanian = "Romanian"; case russian = "Russian"; case sango = "Sango"; case serbian = "Serbian"; case shona = "Shona"; case slovak = "Slovak"; case slovenian = "Slovenian"; case somali = "Somali"; case spanish = "Spanish"; case swahili = "Swahili"; case swedish = "Swedish"; case tagalog = "Tagalog"; case thai = "Thai"; case tigrinya = "Tigrinya"; case turkish = "Turkish"; case ukrainian = "Ukrainian"; case urdu = "Urdu"; case vietnamese = "Vietnamese"; case zulu = "Zulu" - // swiftlint:enable line_length -} diff --git a/EhPanda/Models/Persistent/AppEnv.swift b/EhPanda/Models/Persistent/AppEnv.swift new file mode 100644 index 00000000..62a1d246 --- /dev/null +++ b/EhPanda/Models/Persistent/AppEnv.swift @@ -0,0 +1,29 @@ +// +// AppEnv.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/04. +// + +struct AppEnv: Codable { + let user: User + let setting: Setting + let searchFilter: Filter + let globalFilter: Filter + let watchedFilter: Filter + let tagTranslator: TagTranslator + let historyKeywords: [String] + let quickSearchWords: [QuickSearchWord] +} + +extension AppEnv: CustomStringConvertible { + var description: String { + .init(describing: [ + "user": user, + "setting": setting, + "tagTranslator": tagTranslator, + "historyKeywordsCount": historyKeywords.count, + "quickSearchWordsCount": quickSearchWords.count + ]) + } +} diff --git a/EhPanda/Models/Persistent/Filter.swift b/EhPanda/Models/Persistent/Filter.swift new file mode 100644 index 00000000..0c03406f --- /dev/null +++ b/EhPanda/Models/Persistent/Filter.swift @@ -0,0 +1,101 @@ +// +// Filter.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/01/08. +// + +import SwiftUI + +struct Filter: Codable, Equatable { + var doujinshi = false + var manga = false + var artistCG = false + var gameCG = false + var western = false + var nonH = false + var imageSet = false + var cosplay = false + var asianPorn = false + var misc = false + + var advanced = false + var galleryName = true + var galleryTags = true + var galleryDesc = false + var torrentFilenames = false + var onlyWithTorrents = false + var lowPowerTags = false { + didSet { + if lowPowerTags { + downvotedTags = false + } + } + } + var downvotedTags = false { + didSet { + if downvotedTags { + lowPowerTags = false + } + } + } + var expungedGalleries = false + + var minRatingActivated = false + var minRating = 2 + + var pageRangeActivated = false + var pageLowerBound = "" + var pageUpperBound = "" + + var disableLanguage = false + var disableUploader = false + var disableTags = false + + mutating func fixInvalidData() { + if !pageLowerBound.isEmpty && Int(pageLowerBound) == nil { + pageLowerBound = "" + } + if !pageUpperBound.isEmpty && Int(pageUpperBound) == nil { + pageUpperBound = "" + } + } +} + +// MARK: Manually decode +extension Filter { + init(from decoder: Decoder) { + let container = try? decoder.container(keyedBy: CodingKeys.self) + doujinshi = (try? container?.decodeIfPresent(Bool.self, forKey: .doujinshi)) ?? false + manga = (try? container?.decodeIfPresent(Bool.self, forKey: .manga)) ?? false + artistCG = (try? container?.decodeIfPresent(Bool.self, forKey: .artistCG)) ?? false + gameCG = (try? container?.decodeIfPresent(Bool.self, forKey: .gameCG)) ?? false + western = (try? container?.decodeIfPresent(Bool.self, forKey: .western)) ?? false + nonH = (try? container?.decodeIfPresent(Bool.self, forKey: .nonH)) ?? false + imageSet = (try? container?.decodeIfPresent(Bool.self, forKey: .imageSet)) ?? false + cosplay = (try? container?.decodeIfPresent(Bool.self, forKey: .cosplay)) ?? false + asianPorn = (try? container?.decodeIfPresent(Bool.self, forKey: .asianPorn)) ?? false + misc = (try? container?.decodeIfPresent(Bool.self, forKey: .misc)) ?? false + + advanced = (try? container?.decodeIfPresent(Bool.self, forKey: .advanced)) ?? false + galleryName = (try? container?.decodeIfPresent(Bool.self, forKey: .galleryName)) ?? false + galleryTags = (try? container?.decodeIfPresent(Bool.self, forKey: .galleryTags)) ?? false + galleryDesc = (try? container?.decodeIfPresent(Bool.self, forKey: .galleryDesc)) ?? false + torrentFilenames = (try? container?.decodeIfPresent(Bool.self, forKey: .torrentFilenames)) ?? false + onlyWithTorrents = (try? container?.decodeIfPresent(Bool.self, forKey: .onlyWithTorrents)) ?? false + lowPowerTags = (try? container?.decodeIfPresent(Bool.self, forKey: .lowPowerTags)) ?? false + downvotedTags = (try? container?.decodeIfPresent(Bool.self, forKey: .downvotedTags)) ?? false + expungedGalleries = (try? container?.decodeIfPresent(Bool.self, forKey: .expungedGalleries)) ?? false + + minRatingActivated = (try? container?.decodeIfPresent(Bool.self, forKey: .minRatingActivated)) ?? false + minRating = (try? container?.decodeIfPresent(Int.self, forKey: .minRating)) ?? 2 + + pageRangeActivated = (try? container?.decodeIfPresent(Bool.self, forKey: .pageRangeActivated)) ?? false + pageLowerBound = (try? container?.decodeIfPresent(String.self, forKey: .pageLowerBound)) ?? "" + pageUpperBound = (try? container?.decodeIfPresent(String.self, forKey: .pageUpperBound)) ?? "" + + disableLanguage = (try? container?.decodeIfPresent(Bool.self, forKey: .disableLanguage)) ?? false + disableUploader = (try? container?.decodeIfPresent(Bool.self, forKey: .disableUploader)) ?? false + disableTags = (try? container?.decodeIfPresent(Bool.self, forKey: .disableTags)) ?? false + } +} diff --git a/EhPanda/Models/Persistent/Greeting.swift b/EhPanda/Models/Persistent/Greeting.swift new file mode 100644 index 00000000..d73bd644 --- /dev/null +++ b/EhPanda/Models/Persistent/Greeting.swift @@ -0,0 +1,67 @@ +// +// Greeting.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/01. +// + +import Foundation + +struct Greeting: Codable, Equatable, Hashable { + static let mock: Self = { + var greeting = Greeting() + greeting.gainedEXP = 10 + greeting.gainedCredits = 10000 + greeting.gainedGP = 10000 + greeting.gainedHath = 10 + return greeting + }() + + var gainedEXP: Int? + var gainedCredits: Int? + var gainedGP: Int? + var gainedHath: Int? + var updateTime: Date? + + var rewards: [String] { + var rewards = [String]() + if let exp = gainedEXP { + rewards.append("\(exp) EXP") + } + if let credits = gainedCredits { + rewards.append("\(credits) Credits") + } + if let galleryPoint = gainedGP { + rewards.append("\(galleryPoint) GP") + } + if let hath = gainedHath { + rewards.append("\(hath) Hath") + } + return rewards + } + + var gainContent: String? { + let rewards = rewards + guard !rewards.isEmpty else { return nil } + let and = R.string.localizable.structGreetingMarkAnd() + let end = R.string.localizable.structGreetingMarkEnd() + let start = R.string.localizable.structGreetingMarkStart() + let separator = R.string.localizable.structGreetingMarkSeparator() + let rewardDescription = rewards.enumerated().map { (offset, element) in + if offset == 0 { + return element + } else if offset == rewards.count - 1 { + return [rewards.count > 2 ? and : separator, element].joined() + } else { + return [separator, element].joined() + } + } + .joined() + return [start, rewardDescription, end].joined() + } + + var gainedNothing: Bool { + [gainedEXP, gainedCredits, gainedGP, gainedHath] + .compactMap({ $0 }).isEmpty + } +} diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift new file mode 100644 index 00000000..df1f6ff2 --- /dev/null +++ b/EhPanda/Models/Persistent/Setting.swift @@ -0,0 +1,199 @@ +// +// Setting.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/01/09. +// + +import SwiftUI +import Foundation +import ComposableArchitecture + +struct Setting: Codable, Equatable { + // Account + var galleryHost: GalleryHost = .ehentai + var showsNewDawnGreeting = false + + // General + var redirectsLinksToSelectedHost = false + var detectsLinksFromClipboard = false + var backgroundBlurRadius: Double = 10 + var autoLockPolicy: AutoLockPolicy = .never + + // Appearance + var listDisplayMode: ListDisplayMode = DeviceUtil.isPadWidth ? .thumbnail : .detail + var preferredColorScheme = PreferredColorScheme.automatic + var accentColor: Color = .blue + var appIconType: AppIconType = .default + var translatesTags = false + var showsTagsInList = false + var listTagsNumberMaximum = 0 + + // Reading + var readingDirection: ReadingDirection = .vertical + var prefetchLimit = 10 + var enablesLandscape = false + var enablesDualPageMode = false + var exceptCover = false + var contentDividerHeight: Double = 0 + var maximumScaleFactor: Double = 3 + var doubleTapScaleFactor: Double = 2 + + // Laboratory + var bypassesSNIFiltering = false +} + +enum GalleryHost: String, Codable, CaseIterable, Identifiable { + case ehentai = "E-Hentai" + case exhentai = "ExHentai" + + var id: Int { hashValue } + var url: URL { + switch self { + case .ehentai: + return Defaults.URL.ehentai + case .exhentai: + return Defaults.URL.exhentai + } + } + var abbr: String { + switch self { + case .ehentai: + return "eh" + case .exhentai: + return "ex" + } + } +} + +enum AutoLockPolicy: Int, Codable, CaseIterable, Identifiable { + var id: Int { rawValue } + + case never = -1 + case instantly = 0 + case sec15 = 15 + case min1 = 60 + case min5 = 300 + case min10 = 600 + case min30 = 1800 +} + +extension AutoLockPolicy { + var value: String { + switch self { + case .never: + return R.string.localizable.enumAutoLockPolicyValueNever() + case .instantly: + return R.string.localizable.enumAutoLockPolicyValueInstantly() + case .sec15: + return R.string.localizable.commonValueSeconds("\(rawValue)") + case .min1: + return R.string.localizable.commonValueMinute("\(rawValue / 60)") + case .min5, .min10, .min30: + return R.string.localizable.commonValueMinutes("\(rawValue / 60)") + } + } +} + +enum PreferredColorScheme: Int, Codable, CaseIterable, Identifiable { + var id: Int { rawValue } + + case automatic + case light + case dark +} +extension PreferredColorScheme { + var value: String { + switch self { + case .automatic: + return R.string.localizable.enumPerferredColorSchemeValueAutomatic() + case .light: + return R.string.localizable.enumPerferredColorSchemeValueLight() + case .dark: + return R.string.localizable.enumPerferredColorSchemeValueDark() + } + } + var userInterfaceStyle: UIUserInterfaceStyle { + switch self { + case .automatic: + return .unspecified + case .light: + return .light + case .dark: + return .dark + } + } +} + +enum ReadingDirection: Int, Codable, CaseIterable, Identifiable { + var id: Int { rawValue } + + case vertical + case rightToLeft + case leftToRight +} +extension ReadingDirection { + var value: String { + switch self { + case .vertical: + return R.string.localizable.enumReadingDirectionValueVertical() + case .rightToLeft: + return R.string.localizable.enumReadingDirectionValueRightToLeft() + case .leftToRight: + return R.string.localizable.enumReadingDirectionValueLeftToRight() + } + } +} + +enum ListDisplayMode: Int, Codable, CaseIterable, Identifiable { + var id: Int { rawValue } + + case detail + case thumbnail +} +extension ListDisplayMode { + var value: String { + switch self { + case .detail: + return R.string.localizable.enumDisplayModeValueDetail() + case .thumbnail: + return R.string.localizable.enumDisplayModeValueThumbnail() + } + } +} + +// swiftlint:disable line_length +// MARK: Manually decode +extension Setting { + init(from decoder: Decoder) { + let container = try? decoder.container(keyedBy: CodingKeys.self) + // Account + galleryHost = (try? container?.decodeIfPresent(GalleryHost.self, forKey: .galleryHost)) ?? .ehentai + showsNewDawnGreeting = (try? container?.decodeIfPresent(Bool.self, forKey: .showsNewDawnGreeting)) ?? false + // General + redirectsLinksToSelectedHost = (try? container?.decodeIfPresent(Bool.self, forKey: .redirectsLinksToSelectedHost)) ?? false + detectsLinksFromClipboard = (try? container?.decodeIfPresent(Bool.self, forKey: .detectsLinksFromClipboard)) ?? false + backgroundBlurRadius = (try? container?.decodeIfPresent(Double.self, forKey: .backgroundBlurRadius)) ?? 10 + autoLockPolicy = (try? container?.decodeIfPresent(AutoLockPolicy.self, forKey: .autoLockPolicy)) ?? .never + // Appearance + listDisplayMode = (try? container?.decodeIfPresent(ListDisplayMode.self, forKey: .listDisplayMode)) ?? (DeviceUtil.isPadWidth ? .thumbnail : .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 + translatesTags = (try? container?.decodeIfPresent(Bool.self, forKey: .translatesTags)) ?? false + showsTagsInList = (try? container?.decodeIfPresent(Bool.self, forKey: .showsTagsInList)) ?? false + listTagsNumberMaximum = (try? container?.decodeIfPresent(Int.self, forKey: .listTagsNumberMaximum)) ?? 0 + // Reading + readingDirection = (try? container?.decodeIfPresent(ReadingDirection.self, forKey: .readingDirection)) ?? .vertical + prefetchLimit = (try? container?.decodeIfPresent(Int.self, forKey: .prefetchLimit)) ?? 10 + enablesLandscape = (try? container?.decodeIfPresent(Bool.self, forKey: .enablesLandscape)) ?? false + enablesDualPageMode = (try? container?.decodeIfPresent(Bool.self, forKey: .enablesDualPageMode)) ?? false + exceptCover = (try? container?.decodeIfPresent(Bool.self, forKey: .exceptCover)) ?? false + contentDividerHeight = (try? container?.decodeIfPresent(Double.self, forKey: .contentDividerHeight)) ?? 0 + maximumScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .maximumScaleFactor)) ?? 3 + doubleTapScaleFactor = (try? container?.decodeIfPresent(Double.self, forKey: .doubleTapScaleFactor)) ?? 2 + // Laboratory + bypassesSNIFiltering = (try? container?.decodeIfPresent(Bool.self, forKey: .bypassesSNIFiltering)) ?? false + } +} +// swiftlint:enable line_length diff --git a/EhPanda/Models/Persistent/TagTranslator.swift b/EhPanda/Models/Persistent/TagTranslator.swift new file mode 100644 index 00000000..e843ab80 --- /dev/null +++ b/EhPanda/Models/Persistent/TagTranslator.swift @@ -0,0 +1,44 @@ +// +// TagTranslator.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/04. +// + +import Foundation + +struct TagTranslator: Codable, Equatable { + var language: TranslatableLanguage? + var hasCustomTranslations: Bool = false + var updatedDate: Date = .distantPast + var contents = [String: String]() + + private func lookup(text: String) -> String { + guard let translatedText = contents[text], + !translatedText.isEmpty + else { return text } + + return translatedText + } + func tryTranslate(text: String, returnOriginal: Bool) -> String { + guard !returnOriginal else { return text } + if let range = text.range(of: ":") { + let before = text[...range.lowerBound] + let after = String(text[range.upperBound...]) + let result = before + lookup(text: after) + return String(result) + } + return lookup(text: text) + } +} + +extension TagTranslator: CustomStringConvertible { + var description: String { + .init(describing: [ + "language": language as Any, + "updatedDate": updatedDate, + "contentsCount": contents.count, + "hasCustomTranslations": hasCustomTranslations + ]) + } +} diff --git a/EhPanda/Models/Persistent/User.swift b/EhPanda/Models/Persistent/User.swift new file mode 100644 index 00000000..15d3959d --- /dev/null +++ b/EhPanda/Models/Persistent/User.swift @@ -0,0 +1,53 @@ +// +// User.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/01/03. +// + +import Foundation + +struct User: Codable, Equatable { + static let empty = User() + + var displayName: String? + var avatarURL: URL? + var apikey: String? + + var credits: String? + var galleryPoints: String? + + var greeting: Greeting? + + var favoriteCategories: [Int: String]? + + func getFavoriteCategory(index: Int) -> String { + guard index != -1 else { return R.string.localizable.favoriteCategoryAll() } + let defaultCategory = R.string.localizable.favoriteCategoryDefault("\(index)") + let category = favoriteCategories?[index] ?? defaultCategory + let isDefault = category == "Favorites \(index)" + return isDefault ? defaultCategory : category + } +} + +enum FavoritesType: String, Codable, CaseIterable { + static func getTypeFrom(index: Int) -> FavoritesType { + FavoritesType.allCases.filter({ $0.index == index }).first ?? .all + } + + var index: Int { + Int(rawValue.replacingOccurrences(of: "favorite_", with: "")) ?? -1 + } + + case all = "all" + case favorite0 = "favorite_0" + case favorite1 = "favorite_1" + case favorite2 = "favorite_2" + case favorite3 = "favorite_3" + case favorite4 = "favorite_4" + case favorite5 = "favorite_5" + case favorite6 = "favorite_6" + case favorite7 = "favorite_7" + case favorite8 = "favorite_8" + case favorite9 = "favorite_9" +} diff --git a/EhPanda/Models/Setting.swift b/EhPanda/Models/Setting.swift deleted file mode 100644 index 25d47f8b..00000000 --- a/EhPanda/Models/Setting.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// Setting.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/09. -// - -import SwiftUI -import Foundation -import BetterCodable - -struct Setting: Codable { - // Account - @DefaultFalse var showNewDawnGreeting = false - - // General - @DefaultFalse var redirectsLinksToSelectedHost = false - @DefaultFalse var detectsLinksFromPasteboard = false - @DefaultDoubleValue var backgroundBlurRadius = 10 - @DefaultAutoLockPolicy var autoLockPolicy: AutoLockPolicy = .never - - // Appearance - var colorScheme: ColorScheme? { - switch preferredColorScheme { - case .light: - return .light - case .dark: - return .dark - default: - return nil - } - } - @DefaultListMode var listMode: ListMode = DeviceUtil.isPadWidth ? .thumbnail : .detail - @DefaultPreferredColorScheme var preferredColorScheme = - PreferredColorScheme.automatic - @DefaultColorValue var accentColor: Color = .blue - @DefaultIconType var appIconType: IconType = .default - @DefaultFalse var translatesTags = false - @DefaultFalse var showsSummaryRowTags = false - @DefaultIntegerValue var summaryRowTagsMaximum = 0 - - // Reading - @DefaultReadingDirection var readingDirection: ReadingDirection = .vertical - @DefaultIntegerValue var prefetchLimit = 10 - @DefaultFalse var prefersLandscape = false { - didSet { - if !prefersLandscape && !DeviceUtil.isPad { - AppDelegate.orientationLock = [ - .portrait, .portraitUpsideDown - ] - } - } - } - @DefaultFalse var enablesDualPageMode = false - @DefaultFalse var exceptCover = false - @DefaultDoubleValue var contentDividerHeight: Double = 0 - @DefaultDoubleValue var maximumScaleFactor: Double = 3 { - didSet { - if doubleTapScaleFactor > maximumScaleFactor { - doubleTapScaleFactor = maximumScaleFactor - } - } - } - @DefaultDoubleValue var doubleTapScaleFactor: Double = 2 { - didSet { - if maximumScaleFactor < doubleTapScaleFactor { - maximumScaleFactor = doubleTapScaleFactor - } - } - } - - // Laboratory - @DefaultFalse var bypassesSNIFiltering = false { - didSet { NotificationUtil.post(.bypassesSNIFilteringDidChange) } - } -} - -enum GalleryHost: String, Codable, CaseIterable, Identifiable { - case ehentai = "E-Hentai" - case exhentai = "ExHentai" - - var id: Int { hashValue } - var abbr: String { - switch self { - case .ehentai: - return "eh" - case .exhentai: - return "ex" - } - } -} - -enum AutoLockPolicy: Int, Codable, CaseIterable, Identifiable { - var id: Int { rawValue } - - case never = -1 - case instantly = 0 - case sec15 = 15 - case min1 = 60 - case min5 = 300 - case min10 = 600 - case min30 = 1800 -} - -extension AutoLockPolicy { - var descriptionKey: LocalizedStringKey { - switch self { - case .never: - return "Never" - case .instantly: - return "Instantly" - case .sec15: - return "\(15) seconds" - case .min1: - return "\(1) minute" - case .min5: - return "\(5) minutes" - case .min10: - return "\(10) minute" - case .min30: - return "\(30) minute" - } - } -} - -enum PreferredColorScheme: String, Codable, CaseIterable, Identifiable { - var id: Int { hashValue } - - case automatic = "Automatic" - case light = "Light" - case dark = "Dark" -} - -enum ReadingDirection: String, Codable, CaseIterable, Identifiable { - var id: Int { hashValue } - - case vertical = "READING_DIRECTION_VERTICAL" - case rightToLeft = "Right-to-left" - case leftToRight = "Left-to-right" -} - -enum ListMode: String, Codable, CaseIterable, Identifiable { - var id: Int { hashValue } - - case detail = "LIST_DISPLAY_MODE_DETAIL" - case thumbnail = "LIST_DISPLAY_MODE_THUMBNAIL" -} diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift new file mode 100644 index 00000000..2daaca80 --- /dev/null +++ b/EhPanda/Models/Support/AppError.swift @@ -0,0 +1,160 @@ +// +// AppError.swift +// EhPanda +// +// Created by 荒木辰造 on R 2/12/26. +// + +import Foundation +import SFSafeSymbols + +enum AppError: Error, Identifiable, Equatable, Hashable { + var id: String { localizedDescription } + + case databaseCorrupted(String?) + case copyrightClaim(String) + case ipBanned(BanInterval) + case expunged(String) + case networkingFailed + case webImageFailed + case parseFailed + case noUpdates + case notFound + case unknown +} + +extension AppError: LocalizedError { + var isRetryable: Bool { + switch self { + case .databaseCorrupted, .ipBanned, .networkingFailed, .parseFailed, + .noUpdates, .notFound, .unknown, .webImageFailed: + return true + case .copyrightClaim, .expunged: + return false + } + } + var localizedDescription: String { + switch self { + case .databaseCorrupted: + return "Database Corrupted" + case .copyrightClaim: + return "Copyright Claim" + case .ipBanned: + return "IP Banned" + case .expunged: + return "Gallery Expunged" + case .networkingFailed: + return "Network Error" + case .webImageFailed: + return "Web image loading error" + case .parseFailed: + return "Parse Error" + case .noUpdates: + return "No updates available" + case .notFound: + return "Not found" + case .unknown: + return "Unknown Error" + } + } + var symbol: SFSymbol { + switch self { + case .databaseCorrupted: + return .exclamationmarkTriangleFill + case .ipBanned: + return .networkBadgeShieldHalfFilled + case .copyrightClaim, .expunged: + return .trashCircleFill + case .networkingFailed: + return .wifiExclamationmark + case .parseFailed: + return .rectangleAndTextMagnifyingglass + case .notFound, .unknown, .noUpdates, .webImageFailed: + return .questionmarkCircleFill + } + } + var alertText: String { + let tryLater = R.string.localizable.errorViewTitleTryLater() + switch self { + case .databaseCorrupted(let reason): + var lines = [R.string.localizable.errorViewTitleDatabaseCorrupted()] + if let reason = reason { + lines.append("(\(reason))") + } + return lines.joined(separator: "\n") + case .copyrightClaim(let owner): + return R.string.localizable.errorViewTitleCopyrightClaim(owner) + case .ipBanned(let interval): + return R.string.localizable.errorViewTitleIpBanned(interval.description) + case .expunged(let reason): + switch reason { + case R.string.constant.websiteResponseGalleryUnavailable(): + return R.string.localizable.errorViewTitleGalleryUnavailable() + default: + return reason + } + case .networkingFailed: + return [R.string.localizable.errorViewTitleNetwork(), tryLater].joined(separator: "\n") + case .parseFailed: + return [R.string.localizable.errorViewTitleParsing(), tryLater].joined(separator: "\n") + case .noUpdates, .webImageFailed: + return "" + case .notFound: + return R.string.localizable.errorViewTitleNotFound() + case .unknown: + return [R.string.localizable.errorViewTitleUnknown(), tryLater].joined(separator: "\n") + } + } +} + +enum BanInterval: Equatable, Hashable { + case days(_: Int, hours: Int?) + case hours(_: Int, minutes: Int?) + case minutes(_: Int, seconds: Int?) + case unrecognized(content: String) +} + +extension BanInterval { + var description: String { + var params: [String] + let and = R.string.localizable.enumBanIntervalDescriptionAnd() + + switch self { + case .days(let days, let hours): + params = [daysWithUnit(days)] + if let hours = hours { + params += [and, hoursWithUnit(hours)] + } + case .hours(let hours, let minutes): + params = [hoursWithUnit(hours)] + if let minutes = minutes { + params += [and, minutesWithUnit(minutes)] + } + case .minutes(let minutes, let seconds): + params = [minutesWithUnit(minutes)] + if let seconds = seconds { + params += [and, secondsWithUnit(seconds)] + } + case .unrecognized(let content): + params = [content] + } + return params.filter(\.notEmpty).joined(separator: " ") + } + + private func daysWithUnit(_ days: Int) -> String { + days > 1 ? R.string.localizable.commonValueDays("\(days)") + : R.string.localizable.commonValueDay("\(days)") + } + private func hoursWithUnit(_ hours: Int) -> String { + hours > 1 ? R.string.localizable.commonValueHours("\(hours)") + : R.string.localizable.commonValueHour("\(hours)") + } + private func minutesWithUnit(_ minutes: Int) -> String { + minutes > 1 ? R.string.localizable.commonValueMinutes("\(minutes)") + : R.string.localizable.commonValueMinute("\(minutes)") + } + private func secondsWithUnit(_ seconds: Int) -> String { + seconds > 1 ? R.string.localizable.commonValueSeconds("\(seconds)") + : R.string.localizable.commonValueSecond("\(seconds)") + } +} diff --git a/EhPanda/Models/Support/BrowsingCountry.swift b/EhPanda/Models/Support/BrowsingCountry.swift new file mode 100644 index 00000000..e1d1bdcd --- /dev/null +++ b/EhPanda/Models/Support/BrowsingCountry.swift @@ -0,0 +1,527 @@ +// +// BrowsingCountry.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/30. +// + +import Foundation + +// swiftlint:disable line_length +extension EhSetting { + enum BrowsingCountry: String, CaseIterable, Identifiable, Equatable { + case autoDetect = "-"; case afghanistan = "AF"; case alandIslands = "AX"; case albania = "AL"; case algeria = "DZ"; case americanSamoa = "AS"; case andorra = "AD"; case angola = "AO"; case anguilla = "AI"; case antarctica = "AQ"; case antiguaAndBarbuda = "AG"; case argentina = "AR"; case armenia = "AM"; case aruba = "AW"; case asiaPacificRegion = "AP"; case australia = "AU"; case austria = "AT"; case azerbaijan = "AZ"; case bahamas = "BS"; case bahrain = "BH"; case bangladesh = "BD"; case barbados = "BB"; case belarus = "BY"; case belgium = "BE"; case belize = "BZ"; case benin = "BJ"; case bermuda = "BM"; case bhutan = "BT"; case bolivia = "BO"; case bonaireSaintEustatiusAndSaba = "BQ"; case bosniaAndHerzegovina = "BA"; case botswana = "BW"; case bouvetIsland = "BV"; case brazil = "BR"; case britishIndianOceanTerritory = "IO"; case bruneiDarussalam = "BN"; case bulgaria = "BG"; case burkinaFaso = "BF"; case burundi = "BI"; case cambodia = "KH"; case cameroon = "CM"; case canada = "CA"; case capeVerde = "CV"; case caymanIslands = "KY"; case centralAfricanRepublic = "CF"; case chad = "TD"; case chile = "CL"; case china = "CN"; case christmasIsland = "CX"; case cocosIslands = "CC"; case colombia = "CO"; case comoros = "KM"; case congo = "CG"; case theDemocraticRepublicOfTheCongo = "CD"; case cookIslands = "CK"; case costaRica = "CR"; case coteDIvoire = "CI"; case croatia = "HR"; case cuba = "CU"; case curacao = "CW"; case cyprus = "CY"; case czechRepublic = "CZ"; case denmark = "DK"; case djibouti = "DJ"; case dominica = "DM"; case dominicanRepublic = "DO"; case ecuador = "EC"; case egypt = "EG"; case elSalvador = "SV"; case equatorialGuinea = "GQ"; case eritrea = "ER"; case estonia = "EE"; case ethiopia = "ET"; case europe = "EU"; case falklandIslands = "FK"; case faroeIslands = "FO"; case fiji = "FJ"; case finland = "FI"; case france = "FR"; case frenchGuiana = "GF"; case frenchPolynesia = "PF"; case frenchSouthernTerritories = "TF"; case gabon = "GA"; case gambia = "GM"; case georgia = "GE"; case germany = "DE"; case ghana = "GH"; case gibraltar = "GI"; case greece = "GR"; case greenland = "GL"; case grenada = "GD"; case guadeloupe = "GP"; case guam = "GU"; case guatemala = "GT"; case guernsey = "GG"; case guinea = "GN"; case guineaBissau = "GW"; case guyana = "GY"; case haiti = "HT"; case heardIslandAndMcDonaldIslands = "HM"; case vaticanCityState = "VA"; case honduras = "HN"; case hongKong = "HK"; case hungary = "HU"; case iceland = "IS"; case india = "IN"; case indonesia = "ID"; case iran = "IR"; case iraq = "IQ"; case ireland = "IE"; case isleOfMan = "IM"; case israel = "IL"; case italy = "IT"; case jamaica = "JM"; case japan = "JP"; case jersey = "JE"; case jordan = "JO"; case kazakhstan = "KZ"; case kenya = "KE"; case kiribati = "KI"; case kuwait = "KW"; case kyrgyzstan = "KG"; case laoPeoplesDemocraticRepublic = "LA"; case latvia = "LV"; case lebanon = "LB"; case lesotho = "LS"; case liberia = "LR"; case libya = "LY"; case liechtenstein = "LI"; case lithuania = "LT"; case luxembourg = "LU"; case macau = "MO"; case macedonia = "MK"; case madagascar = "MG"; case malawi = "MW"; case malaysia = "MY"; case maldives = "MV"; case mali = "ML"; case malta = "MT"; case marshallIslands = "MH"; case martinique = "MQ"; case mauritania = "MR"; case mauritius = "MU"; case mayotte = "YT"; case mexico = "MX"; case micronesia = "FM"; case moldova = "MD"; case monaco = "MC"; case mongolia = "MN"; case montenegro = "ME"; case montserrat = "MS"; case morocco = "MA"; case mozambique = "MZ"; case myanmar = "MM"; case namibia = "NA"; case nauru = "NR"; case nepal = "NP"; case netherlands = "NL"; case newCaledonia = "NC"; case newZealand = "NZ"; case nicaragua = "NI"; case niger = "NE"; case nigeria = "NG"; case niue = "NU"; case norfolkIsland = "NF"; case northKorea = "KP"; case northernMarianaIslands = "MP"; case norway = "NO"; case oman = "OM"; case pakistan = "PK"; case palau = "PW"; case palestinianTerritory = "PS"; case panama = "PA"; case papuaNewGuinea = "PG"; case paraguay = "PY"; case peru = "PE"; case philippines = "PH"; case pitcairnIslands = "PN"; case poland = "PL"; case portugal = "PT"; case puertoRico = "PR"; case qatar = "QA"; case reunion = "RE"; case romania = "RO"; case russianFederation = "RU"; case rwanda = "RW"; case saintBarthelemy = "BL"; case saintHelena = "SH"; case saintKittsAndNevis = "KN"; case saintLucia = "LC"; case saintMartin = "MF"; case saintPierreAndMiquelon = "PM"; case saintVincentAndTheGrenadines = "VC"; case samoa = "WS"; case sanMarino = "SM"; case saoTomeAndPrincipe = "ST"; case saudiArabia = "SA"; case senegal = "SN"; case serbia = "RS"; case seychelles = "SC"; case sierraLeone = "SL"; case singapore = "SG"; case sintMaarten = "SX"; case slovakia = "SK"; case slovenia = "SI"; case solomonIslands = "SB"; case somalia = "SO"; case southAfrica = "ZA"; case southGeorgiaAndTheSouthSandwichIslands = "GS"; case southKorea = "KR"; case southSudan = "SS"; case spain = "ES"; case sriLanka = "LK"; case sudan = "SD"; case suriname = "SR"; case svalbardAndJanMayen = "SJ"; case swaziland = "SZ"; case sweden = "SE"; case switzerland = "CH"; case syrianArabRepublic = "SY"; case taiwan = "TW"; case tajikistan = "TJ"; case tanzania = "TZ"; case thailand = "TH"; case timorLeste = "TL"; case togo = "TG"; case tokelau = "TK"; case tonga = "TO"; case trinidadAndTobago = "TT"; case tunisia = "TN"; case turkey = "TR"; case turkmenistan = "TM"; case turksAndCaicosIslands = "TC"; case tuvalu = "TV"; case uganda = "UG"; case ukraine = "UA"; case unitedArabEmirates = "AE"; case unitedKingdom = "GB"; case unitedStates = "US"; case unitedStatesMinorOutlyingIslands = "UM"; case uruguay = "UY"; case uzbekistan = "UZ"; case vanuatu = "VU"; case venezuela = "VE"; case vietnam = "VN"; case virginIslandsBritish = "VG"; case virginIslandsUS = "VI"; case wallisAndFutuna = "WF"; case westernSahara = "EH"; case yemen = "YE"; case zambia = "ZM"; case zimbabwe = "ZW" + } +} +// swiftlint:enable line_length +extension EhSetting.BrowsingCountry { + var id: Int { hashValue } + var name: String { + switch self { + case .autoDetect: + return R.string.localizable.enumBrowsingCountryNameAutoDetect() + case .afghanistan: + return R.string.localizable.enumBrowsingCountryNameAfghanistan() + case .alandIslands: + return R.string.localizable.enumBrowsingCountryNameAlandIslands() + case .albania: + return R.string.localizable.enumBrowsingCountryNameAlbania() + case .algeria: + return R.string.localizable.enumBrowsingCountryNameAlgeria() + case .americanSamoa: + return R.string.localizable.enumBrowsingCountryNameAmericanSamoa() + case .andorra: + return R.string.localizable.enumBrowsingCountryNameAndorra() + case .angola: + return R.string.localizable.enumBrowsingCountryNameAngola() + case .anguilla: + return R.string.localizable.enumBrowsingCountryNameAnguilla() + case .antarctica: + return R.string.localizable.enumBrowsingCountryNameAntarctica() + case .antiguaAndBarbuda: + return R.string.localizable.enumBrowsingCountryNameAntiguaAndBarbuda() + case .argentina: + return R.string.localizable.enumBrowsingCountryNameArgentina() + case .armenia: + return R.string.localizable.enumBrowsingCountryNameArmenia() + case .aruba: + return R.string.localizable.enumBrowsingCountryNameAruba() + case .asiaPacificRegion: + return R.string.localizable.enumBrowsingCountryNameAsiaPacificRegion() + case .australia: + return R.string.localizable.enumBrowsingCountryNameAustralia() + case .austria: + return R.string.localizable.enumBrowsingCountryNameAustria() + case .azerbaijan: + return R.string.localizable.enumBrowsingCountryNameAzerbaijan() + case .bahamas: + return R.string.localizable.enumBrowsingCountryNameBahamas() + case .bahrain: + return R.string.localizable.enumBrowsingCountryNameBahrain() + case .bangladesh: + return R.string.localizable.enumBrowsingCountryNameBangladesh() + case .barbados: + return R.string.localizable.enumBrowsingCountryNameBarbados() + case .belarus: + return R.string.localizable.enumBrowsingCountryNameBelarus() + case .belgium: + return R.string.localizable.enumBrowsingCountryNameBelgium() + case .belize: + return R.string.localizable.enumBrowsingCountryNameBelize() + case .benin: + return R.string.localizable.enumBrowsingCountryNameBenin() + case .bermuda: + return R.string.localizable.enumBrowsingCountryNameBermuda() + case .bhutan: + return R.string.localizable.enumBrowsingCountryNameBhutan() + case .bolivia: + return R.string.localizable.enumBrowsingCountryNameBolivia() + case .bonaireSaintEustatiusAndSaba: + return R.string.localizable.enumBrowsingCountryNameBonaireSaintEustatiusAndSaba() + case .bosniaAndHerzegovina: + return R.string.localizable.enumBrowsingCountryNameBosniaAndHerzegovina() + case .botswana: + return R.string.localizable.enumBrowsingCountryNameBotswana() + case .bouvetIsland: + return R.string.localizable.enumBrowsingCountryNameBouvetIsland() + case .brazil: + return R.string.localizable.enumBrowsingCountryNameBrazil() + case .britishIndianOceanTerritory: + return R.string.localizable.enumBrowsingCountryNameBritishIndianOceanTerritory() + case .bruneiDarussalam: + return R.string.localizable.enumBrowsingCountryNameBruneiDarussalam() + case .bulgaria: + return R.string.localizable.enumBrowsingCountryNameBulgaria() + case .burkinaFaso: + return R.string.localizable.enumBrowsingCountryNameBurkinaFaso() + case .burundi: + return R.string.localizable.enumBrowsingCountryNameBurundi() + case .cambodia: + return R.string.localizable.enumBrowsingCountryNameCambodia() + case .cameroon: + return R.string.localizable.enumBrowsingCountryNameCameroon() + case .canada: + return R.string.localizable.enumBrowsingCountryNameCanada() + case .capeVerde: + return R.string.localizable.enumBrowsingCountryNameCapeVerde() + case .caymanIslands: + return R.string.localizable.enumBrowsingCountryNameCaymanIslands() + case .centralAfricanRepublic: + return R.string.localizable.enumBrowsingCountryNameCentralAfricanRepublic() + case .chad: + return R.string.localizable.enumBrowsingCountryNameChad() + case .chile: + return R.string.localizable.enumBrowsingCountryNameChile() + case .china: + return R.string.localizable.enumBrowsingCountryNameChina() + case .christmasIsland: + return R.string.localizable.enumBrowsingCountryNameChristmasIsland() + case .cocosIslands: + return R.string.localizable.enumBrowsingCountryNameCocosIslands() + case .colombia: + return R.string.localizable.enumBrowsingCountryNameColombia() + case .comoros: + return R.string.localizable.enumBrowsingCountryNameComoros() + case .congo: + return R.string.localizable.enumBrowsingCountryNameCongo() + case .theDemocraticRepublicOfTheCongo: + return R.string.localizable.enumBrowsingCountryNameTheDemocraticRepublicOfTheCongo() + case .cookIslands: + return R.string.localizable.enumBrowsingCountryNameCookIslands() + case .costaRica: + return R.string.localizable.enumBrowsingCountryNameCostaRica() + case .coteDIvoire: + return R.string.localizable.enumBrowsingCountryNameCoteDIvoire() + case .croatia: + return R.string.localizable.enumBrowsingCountryNameCroatia() + case .cuba: + return R.string.localizable.enumBrowsingCountryNameCuba() + case .curacao: + return R.string.localizable.enumBrowsingCountryNameCuracao() + case .cyprus: + return R.string.localizable.enumBrowsingCountryNameCyprus() + case .czechRepublic: + return R.string.localizable.enumBrowsingCountryNameCzechRepublic() + case .denmark: + return R.string.localizable.enumBrowsingCountryNameDenmark() + case .djibouti: + return R.string.localizable.enumBrowsingCountryNameDjibouti() + case .dominica: + return R.string.localizable.enumBrowsingCountryNameDominica() + case .dominicanRepublic: + return R.string.localizable.enumBrowsingCountryNameDominicanRepublic() + case .ecuador: + return R.string.localizable.enumBrowsingCountryNameEcuador() + case .egypt: + return R.string.localizable.enumBrowsingCountryNameEgypt() + case .elSalvador: + return R.string.localizable.enumBrowsingCountryNameElSalvador() + case .equatorialGuinea: + return R.string.localizable.enumBrowsingCountryNameEquatorialGuinea() + case .eritrea: + return R.string.localizable.enumBrowsingCountryNameEritrea() + case .estonia: + return R.string.localizable.enumBrowsingCountryNameEstonia() + case .ethiopia: + return R.string.localizable.enumBrowsingCountryNameEthiopia() + case .europe: + return R.string.localizable.enumBrowsingCountryNameEurope() + case .falklandIslands: + return R.string.localizable.enumBrowsingCountryNameFalklandIslands() + case .faroeIslands: + return R.string.localizable.enumBrowsingCountryNameFaroeIslands() + case .fiji: + return R.string.localizable.enumBrowsingCountryNameFiji() + case .finland: + return R.string.localizable.enumBrowsingCountryNameFinland() + case .france: + return R.string.localizable.enumBrowsingCountryNameFrance() + case .frenchGuiana: + return R.string.localizable.enumBrowsingCountryNameFrenchGuiana() + case .frenchPolynesia: + return R.string.localizable.enumBrowsingCountryNameFrenchPolynesia() + case .frenchSouthernTerritories: + return R.string.localizable.enumBrowsingCountryNameFrenchSouthernTerritories() + case .gabon: + return R.string.localizable.enumBrowsingCountryNameGabon() + case .gambia: + return R.string.localizable.enumBrowsingCountryNameGambia() + case .georgia: + return R.string.localizable.enumBrowsingCountryNameGeorgia() + case .germany: + return R.string.localizable.enumBrowsingCountryNameGermany() + case .ghana: + return R.string.localizable.enumBrowsingCountryNameGhana() + case .gibraltar: + return R.string.localizable.enumBrowsingCountryNameGibraltar() + case .greece: + return R.string.localizable.enumBrowsingCountryNameGreece() + case .greenland: + return R.string.localizable.enumBrowsingCountryNameGreenland() + case .grenada: + return R.string.localizable.enumBrowsingCountryNameGrenada() + case .guadeloupe: + return R.string.localizable.enumBrowsingCountryNameGuadeloupe() + case .guam: + return R.string.localizable.enumBrowsingCountryNameGuam() + case .guatemala: + return R.string.localizable.enumBrowsingCountryNameGuatemala() + case .guernsey: + return R.string.localizable.enumBrowsingCountryNameGuernsey() + case .guinea: + return R.string.localizable.enumBrowsingCountryNameGuinea() + case .guineaBissau: + return R.string.localizable.enumBrowsingCountryNameGuineaBissau() + case .guyana: + return R.string.localizable.enumBrowsingCountryNameGuyana() + case .haiti: + return R.string.localizable.enumBrowsingCountryNameHaiti() + case .heardIslandAndMcDonaldIslands: + return R.string.localizable.enumBrowsingCountryNameHeardIslandAndMcDonaldIslands() + case .vaticanCityState: + return R.string.localizable.enumBrowsingCountryNameVaticanCityState() + case .honduras: + return R.string.localizable.enumBrowsingCountryNameHonduras() + case .hongKong: + return R.string.localizable.enumBrowsingCountryNameHongKong() + case .hungary: + return R.string.localizable.enumBrowsingCountryNameHungary() + case .iceland: + return R.string.localizable.enumBrowsingCountryNameIceland() + case .india: + return R.string.localizable.enumBrowsingCountryNameIndia() + case .indonesia: + return R.string.localizable.enumBrowsingCountryNameIndonesia() + case .iran: + return R.string.localizable.enumBrowsingCountryNameIran() + case .iraq: + return R.string.localizable.enumBrowsingCountryNameIraq() + case .ireland: + return R.string.localizable.enumBrowsingCountryNameIreland() + case .isleOfMan: + return R.string.localizable.enumBrowsingCountryNameIsleOfMan() + case .israel: + return R.string.localizable.enumBrowsingCountryNameIsrael() + case .italy: + return R.string.localizable.enumBrowsingCountryNameItaly() + case .jamaica: + return R.string.localizable.enumBrowsingCountryNameJamaica() + case .japan: + return R.string.localizable.enumBrowsingCountryNameJapan() + case .jersey: + return R.string.localizable.enumBrowsingCountryNameJersey() + case .jordan: + return R.string.localizable.enumBrowsingCountryNameJordan() + case .kazakhstan: + return R.string.localizable.enumBrowsingCountryNameKazakhstan() + case .kenya: + return R.string.localizable.enumBrowsingCountryNameKenya() + case .kiribati: + return R.string.localizable.enumBrowsingCountryNameKiribati() + case .kuwait: + return R.string.localizable.enumBrowsingCountryNameKuwait() + case .kyrgyzstan: + return R.string.localizable.enumBrowsingCountryNameKyrgyzstan() + case .laoPeoplesDemocraticRepublic: + return R.string.localizable.enumBrowsingCountryNameLaoPeoplesDemocraticRepublic() + case .latvia: + return R.string.localizable.enumBrowsingCountryNameLatvia() + case .lebanon: + return R.string.localizable.enumBrowsingCountryNameLebanon() + case .lesotho: + return R.string.localizable.enumBrowsingCountryNameLesotho() + case .liberia: + return R.string.localizable.enumBrowsingCountryNameLiberia() + case .libya: + return R.string.localizable.enumBrowsingCountryNameLibya() + case .liechtenstein: + return R.string.localizable.enumBrowsingCountryNameLiechtenstein() + case .lithuania: + return R.string.localizable.enumBrowsingCountryNameLithuania() + case .luxembourg: + return R.string.localizable.enumBrowsingCountryNameLuxembourg() + case .macau: + return R.string.localizable.enumBrowsingCountryNameMacau() + case .macedonia: + return R.string.localizable.enumBrowsingCountryNameMacedonia() + case .madagascar: + return R.string.localizable.enumBrowsingCountryNameMadagascar() + case .malawi: + return R.string.localizable.enumBrowsingCountryNameMalawi() + case .malaysia: + return R.string.localizable.enumBrowsingCountryNameMalaysia() + case .maldives: + return R.string.localizable.enumBrowsingCountryNameMaldives() + case .mali: + return R.string.localizable.enumBrowsingCountryNameMali() + case .malta: + return R.string.localizable.enumBrowsingCountryNameMalta() + case .marshallIslands: + return R.string.localizable.enumBrowsingCountryNameMarshallIslands() + case .martinique: + return R.string.localizable.enumBrowsingCountryNameMartinique() + case .mauritania: + return R.string.localizable.enumBrowsingCountryNameMauritania() + case .mauritius: + return R.string.localizable.enumBrowsingCountryNameMauritius() + case .mayotte: + return R.string.localizable.enumBrowsingCountryNameMayotte() + case .mexico: + return R.string.localizable.enumBrowsingCountryNameMexico() + case .micronesia: + return R.string.localizable.enumBrowsingCountryNameMicronesia() + case .moldova: + return R.string.localizable.enumBrowsingCountryNameMoldova() + case .monaco: + return R.string.localizable.enumBrowsingCountryNameMonaco() + case .mongolia: + return R.string.localizable.enumBrowsingCountryNameMongolia() + case .montenegro: + return R.string.localizable.enumBrowsingCountryNameMontenegro() + case .montserrat: + return R.string.localizable.enumBrowsingCountryNameMontserrat() + case .morocco: + return R.string.localizable.enumBrowsingCountryNameMorocco() + case .mozambique: + return R.string.localizable.enumBrowsingCountryNameMozambique() + case .myanmar: + return R.string.localizable.enumBrowsingCountryNameMyanmar() + case .namibia: + return R.string.localizable.enumBrowsingCountryNameNamibia() + case .nauru: + return R.string.localizable.enumBrowsingCountryNameNauru() + case .nepal: + return R.string.localizable.enumBrowsingCountryNameNepal() + case .netherlands: + return R.string.localizable.enumBrowsingCountryNameNetherlands() + case .newCaledonia: + return R.string.localizable.enumBrowsingCountryNameNewCaledonia() + case .newZealand: + return R.string.localizable.enumBrowsingCountryNameNewZealand() + case .nicaragua: + return R.string.localizable.enumBrowsingCountryNameNicaragua() + case .niger: + return R.string.localizable.enumBrowsingCountryNameNiger() + case .nigeria: + return R.string.localizable.enumBrowsingCountryNameNigeria() + case .niue: + return R.string.localizable.enumBrowsingCountryNameNiue() + case .norfolkIsland: + return R.string.localizable.enumBrowsingCountryNameNorfolkIsland() + case .northKorea: + return R.string.localizable.enumBrowsingCountryNameNorthKorea() + case .northernMarianaIslands: + return R.string.localizable.enumBrowsingCountryNameNorthernMarianaIslands() + case .norway: + return R.string.localizable.enumBrowsingCountryNameNorway() + case .oman: + return R.string.localizable.enumBrowsingCountryNameOman() + case .pakistan: + return R.string.localizable.enumBrowsingCountryNamePakistan() + case .palau: + return R.string.localizable.enumBrowsingCountryNamePalau() + case .palestinianTerritory: + return R.string.localizable.enumBrowsingCountryNamePalestinianTerritory() + case .panama: + return R.string.localizable.enumBrowsingCountryNamePanama() + case .papuaNewGuinea: + return R.string.localizable.enumBrowsingCountryNamePapuaNewGuinea() + case .paraguay: + return R.string.localizable.enumBrowsingCountryNameParaguay() + case .peru: + return R.string.localizable.enumBrowsingCountryNamePeru() + case .philippines: + return R.string.localizable.enumBrowsingCountryNamePhilippines() + case .pitcairnIslands: + return R.string.localizable.enumBrowsingCountryNamePitcairnIslands() + case .poland: + return R.string.localizable.enumBrowsingCountryNamePoland() + case .portugal: + return R.string.localizable.enumBrowsingCountryNamePortugal() + case .puertoRico: + return R.string.localizable.enumBrowsingCountryNamePuertoRico() + case .qatar: + return R.string.localizable.enumBrowsingCountryNameQatar() + case .reunion: + return R.string.localizable.enumBrowsingCountryNameReunion() + case .romania: + return R.string.localizable.enumBrowsingCountryNameRomania() + case .russianFederation: + return R.string.localizable.enumBrowsingCountryNameRussianFederation() + case .rwanda: + return R.string.localizable.enumBrowsingCountryNameRwanda() + case .saintBarthelemy: + return R.string.localizable.enumBrowsingCountryNameSaintBarthelemy() + case .saintHelena: + return R.string.localizable.enumBrowsingCountryNameSaintHelena() + case .saintKittsAndNevis: + return R.string.localizable.enumBrowsingCountryNameSaintKittsAndNevis() + case .saintLucia: + return R.string.localizable.enumBrowsingCountryNameSaintLucia() + case .saintMartin: + return R.string.localizable.enumBrowsingCountryNameSaintMartin() + case .saintPierreAndMiquelon: + return R.string.localizable.enumBrowsingCountryNameSaintPierreAndMiquelon() + case .saintVincentAndTheGrenadines: + return R.string.localizable.enumBrowsingCountryNameSaintVincentAndTheGrenadines() + case .samoa: + return R.string.localizable.enumBrowsingCountryNameSamoa() + case .sanMarino: + return R.string.localizable.enumBrowsingCountryNameSanMarino() + case .saoTomeAndPrincipe: + return R.string.localizable.enumBrowsingCountryNameSaoTomeAndPrincipe() + case .saudiArabia: + return R.string.localizable.enumBrowsingCountryNameSaudiArabia() + case .senegal: + return R.string.localizable.enumBrowsingCountryNameSenegal() + case .serbia: + return R.string.localizable.enumBrowsingCountryNameSerbia() + case .seychelles: + return R.string.localizable.enumBrowsingCountryNameSeychelles() + case .sierraLeone: + return R.string.localizable.enumBrowsingCountryNameSierraLeone() + case .singapore: + return R.string.localizable.enumBrowsingCountryNameSingapore() + case .sintMaarten: + return R.string.localizable.enumBrowsingCountryNameSintMaarten() + case .slovakia: + return R.string.localizable.enumBrowsingCountryNameSlovakia() + case .slovenia: + return R.string.localizable.enumBrowsingCountryNameSlovenia() + case .solomonIslands: + return R.string.localizable.enumBrowsingCountryNameSolomonIslands() + case .somalia: + return R.string.localizable.enumBrowsingCountryNameSomalia() + case .southAfrica: + return R.string.localizable.enumBrowsingCountryNameSouthAfrica() + case .southGeorgiaAndTheSouthSandwichIslands: + return R.string.localizable.enumBrowsingCountryNameSouthGeorgiaAndTheSouthSandwichIslands() + case .southKorea: + return R.string.localizable.enumBrowsingCountryNameSouthKorea() + case .southSudan: + return R.string.localizable.enumBrowsingCountryNameSouthSudan() + case .spain: + return R.string.localizable.enumBrowsingCountryNameSpain() + case .sriLanka: + return R.string.localizable.enumBrowsingCountryNameSriLanka() + case .sudan: + return R.string.localizable.enumBrowsingCountryNameSudan() + case .suriname: + return R.string.localizable.enumBrowsingCountryNameSuriname() + case .svalbardAndJanMayen: + return R.string.localizable.enumBrowsingCountryNameSvalbardAndJanMayen() + case .swaziland: + return R.string.localizable.enumBrowsingCountryNameSwaziland() + case .sweden: + return R.string.localizable.enumBrowsingCountryNameSweden() + case .switzerland: + return R.string.localizable.enumBrowsingCountryNameSwitzerland() + case .syrianArabRepublic: + return R.string.localizable.enumBrowsingCountryNameSyrianArabRepublic() + case .taiwan: + return R.string.localizable.enumBrowsingCountryNameTaiwan() + case .tajikistan: + return R.string.localizable.enumBrowsingCountryNameTajikistan() + case .tanzania: + return R.string.localizable.enumBrowsingCountryNameTanzania() + case .thailand: + return R.string.localizable.enumBrowsingCountryNameThailand() + case .timorLeste: + return R.string.localizable.enumBrowsingCountryNameTimorLeste() + case .togo: + return R.string.localizable.enumBrowsingCountryNameTogo() + case .tokelau: + return R.string.localizable.enumBrowsingCountryNameTokelau() + case .tonga: + return R.string.localizable.enumBrowsingCountryNameTonga() + case .trinidadAndTobago: + return R.string.localizable.enumBrowsingCountryNameTrinidadAndTobago() + case .tunisia: + return R.string.localizable.enumBrowsingCountryNameTunisia() + case .turkey: + return R.string.localizable.enumBrowsingCountryNameTurkey() + case .turkmenistan: + return R.string.localizable.enumBrowsingCountryNameTurkmenistan() + case .turksAndCaicosIslands: + return R.string.localizable.enumBrowsingCountryNameTurksAndCaicosIslands() + case .tuvalu: + return R.string.localizable.enumBrowsingCountryNameTuvalu() + case .uganda: + return R.string.localizable.enumBrowsingCountryNameUganda() + case .ukraine: + return R.string.localizable.enumBrowsingCountryNameUkraine() + case .unitedArabEmirates: + return R.string.localizable.enumBrowsingCountryNameUnitedArabEmirates() + case .unitedKingdom: + return R.string.localizable.enumBrowsingCountryNameUnitedKingdom() + case .unitedStates: + return R.string.localizable.enumBrowsingCountryNameUnitedStates() + case .unitedStatesMinorOutlyingIslands: + return R.string.localizable.enumBrowsingCountryNameUnitedStatesMinorOutlyingIslands() + case .uruguay: + return R.string.localizable.enumBrowsingCountryNameUruguay() + case .uzbekistan: + return R.string.localizable.enumBrowsingCountryNameUzbekistan() + case .vanuatu: + return R.string.localizable.enumBrowsingCountryNameVanuatu() + case .venezuela: + return R.string.localizable.enumBrowsingCountryNameVenezuela() + case .vietnam: + return R.string.localizable.enumBrowsingCountryNameVietnam() + case .virginIslandsBritish: + return R.string.localizable.enumBrowsingCountryNameVirginIslandsBritish() + case .virginIslandsUS: + return R.string.localizable.enumBrowsingCountryNameVirginIslandsUS() + case .wallisAndFutuna: + return R.string.localizable.enumBrowsingCountryNameWallisAndFutuna() + case .westernSahara: + return R.string.localizable.enumBrowsingCountryNameWesternSahara() + case .yemen: + return R.string.localizable.enumBrowsingCountryNameYemen() + case .zambia: + return R.string.localizable.enumBrowsingCountryNameZambia() + case .zimbabwe: + return R.string.localizable.enumBrowsingCountryNameZimbabwe() + } + } +} diff --git a/EhPanda/Models/Support/EhSetting.swift b/EhPanda/Models/Support/EhSetting.swift new file mode 100644 index 00000000..fc5f22b5 --- /dev/null +++ b/EhPanda/Models/Support/EhSetting.swift @@ -0,0 +1,521 @@ +// +// EhSetting.swift +// EhSetting +// +// Created by 荒木辰造 on R 3/08/08. +// + +// MARK: EhSetting +struct EhSetting: Equatable { + // swiftlint:disable line_length + static let empty: Self = .init(ehProfiles: [.empty], capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigSize: .normal, capableThumbnailConfigRowCount: .forty, loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, disabledCategories: Array(repeating: false, count: 10), favoriteCategories: Array(repeating: "", count: 10), favoritesSortOrder: .favoritedTime, ratingsColor: "", excludedNamespaces: Array(repeating: false, count: 11), tagFilteringThreshold: 0, tagWatchingThreshold: 0, excludedLanguages: Array(repeating: false, count: 50), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, thumbnailScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryShowPageNumbers: true /*, hathLocalNetworkHost: "" */) + // swiftlint:enable line_length + + static let categoryNames = Category.allFiltersCases.map(\.rawValue).map { value in + value.lowercased().replacingOccurrences(of: " ", with: "") + } + static let languageValues = [ + 1024, 2048, 1, 1025, 2049, 10, 1034, 2058, + 20, 1044, 2068, 30, 1054, 2078, 40, 1064, 2088, + 50, 1074, 2098, 60, 1084, 2108, 70, 1094, 2118, + 80, 1104, 2128, 90, 1114, 2138, 100, 1124, 2148, + 110, 1134, 2158, 120, 1144, 2168, 130, 1154, 2178, + 254, 1278, 2302, 255, 1279, 2303 + ] + + let ehProfiles: [EhProfile] + var ehpandaProfile: EhProfile? { + ehProfiles.filter({ EhSetting.verifyEhPandaProfileName(with: $0.name) }).first + } + static func verifyEhPandaProfileName(with name: String?) -> Bool { + ["EhPanda", "EhPanda (Default)"].contains(name ?? "") + } + + var capableLoadThroughHathSetting: LoadThroughHathSetting + var capableImageResolution: ImageResolution + var capableSearchResultCount: SearchResultCount + var capableThumbnailConfigSize: ThumbnailSize + var capableThumbnailConfigRowCount: ThumbnailRowCount + + var capableLoadThroughHathSettings: [LoadThroughHathSetting] { + LoadThroughHathSetting.allCases.filter { setting in + setting <= capableLoadThroughHathSetting + } + } + var capableImageResolutions: [ImageResolution] { + ImageResolution.allCases.filter { resolution in + resolution <= capableImageResolution + } + } + var capableSearchResultCounts: [SearchResultCount] { + SearchResultCount.allCases.filter { count in + count <= capableSearchResultCount + } + } + var capableThumbnailConfigSizes: [ThumbnailSize] { + ThumbnailSize.allCases.filter { size in + size <= capableThumbnailConfigSize + } + } + var capableThumbnailConfigRowCounts: [ThumbnailRowCount] { + ThumbnailRowCount.allCases.filter { row in + row <= capableThumbnailConfigRowCount + } + } + + var loadThroughHathSetting: LoadThroughHathSetting + var browsingCountry: BrowsingCountry + let literalBrowsingCountry: String + var imageResolution: ImageResolution + var imageSizeWidth: Float + var imageSizeHeight: Float + var galleryName: GalleryName + var archiverBehavior: ArchiverBehavior + var displayMode: DisplayMode + var disabledCategories: [Bool] + var favoriteCategories: [String] + var favoritesSortOrder: FavoritesSortOrder + var ratingsColor: String + var excludedNamespaces: [Bool] + var tagFilteringThreshold: Float + var tagWatchingThreshold: Float + var excludedLanguages: [Bool] + var excludedUploaders: String + var searchResultCount: SearchResultCount + var thumbnailLoadTiming: ThumbnailLoadTiming + var thumbnailConfigSize: ThumbnailSize + var thumbnailConfigRows: ThumbnailRowCount + var thumbnailScaleFactor: Float + var viewportVirtualWidth: Float + var commentsSortOrder: CommentsSortOrder + var commentVotesShowTiming: CommentVotesShowTiming + var tagsSortOrder: TagsSortOrder + var galleryShowPageNumbers: Bool +// var hathLocalNetworkHost: String + var useOriginalImages: Bool? + var useMultiplePageViewer: Bool? + var multiplePageViewerStyle: MultiplePageViewerStyle? + var multiplePageViewerShowThumbnailPane: Bool? +} + +// MARK: EhProfile +struct EhProfile: Comparable, Identifiable, Hashable { + static let empty: Self = .init( + value: 0, name: "", isSelected: true + ) + static func < (lhs: EhProfile, rhs: EhProfile) -> Bool { + lhs.value < rhs.value + } + var id: Int { value } + + let value: Int + let name: String + let isSelected: Bool + var isDefault: Bool { + value == 1 + } +} +enum EhProfileAction: String { + case create + case delete + case rename + case `default` +} + +// MARK: LoadThroughHathSetting +extension EhSetting { + enum LoadThroughHathSetting: Int, CaseIterable, Identifiable, Comparable { + case anyClient + case defaultPortOnly + case modernNo + case legacyNo + } +} +extension EhSetting.LoadThroughHathSetting { + var id: Int { rawValue } + static func < ( + lhs: EhSetting.LoadThroughHathSetting, + rhs: EhSetting.LoadThroughHathSetting + ) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var value: String { + switch self { + case .anyClient: + return R.string.localizable.enumEhSettingLoadThroughHathSettingValueAnyClient() + case .defaultPortOnly: + return R.string.localizable.enumEhSettingLoadThroughHathSettingValueDefaultPortOnly() + case .modernNo: + return R.string.localizable.enumEhSettingLoadThroughHathSettingValueModernNo() + case .legacyNo: + return R.string.localizable.enumEhSettingLoadThroughHathSettingValueLegacyNo() + } + } + var description: String { + switch self { + case .anyClient: + return R.string.localizable.enumEhSettingLoadThroughHathSettingDescriptionAnyClient() + case .defaultPortOnly: + return R.string.localizable.enumEhSettingLoadThroughHathSettingDescriptionDefaultPortOnly() + case .modernNo: + return R.string.localizable.enumEhSettingLoadThroughHathSettingDescriptionModernNo() + case .legacyNo: + return R.string.localizable.enumEhSettingLoadThroughHathSettingDescriptionLegacyNo() + } + } +} + +// MARK: ImageResolution +extension EhSetting { + enum ImageResolution: Int, CaseIterable, Identifiable, Comparable { + case auto + case x780 + case x980 + case x1280 + case x1600 + case x2400 + } +} +extension EhSetting.ImageResolution { + var id: Int { rawValue } + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var value: String { + switch self { + case .auto: + return R.string.localizable.enumEhSettingImageResolutionValueAuto() + case .x780: + return "780x" + case .x980: + return "980x" + case .x1280: + return "1280x" + case .x1600: + return "1600x" + case .x2400: + return "2400x" + } + } +} + +// MARK: GalleryName +extension EhSetting { + enum GalleryName: Int, CaseIterable, Identifiable { + case `default` + case japanese + } +} +extension EhSetting.GalleryName { + var id: Int { rawValue } + + var value: String { + switch self { + case .default: + return R.string.localizable.enumEhSettingGalleryNameValueDefault() + case .japanese: + return R.string.localizable.enumEhSettingGalleryNameValueJapanese() + } + } +} + +// MARK: ArchiverBehavior +extension EhSetting { + enum ArchiverBehavior: Int, CaseIterable, Identifiable { + case manualSelectManualStart + case manualSelectAutoStart + case autoSelectOriginalManualStart + case autoSelectOriginalAutoStart + case autoSelectResampleManualStart + case autoSelectResampleAutoStart + } +} +extension EhSetting.ArchiverBehavior { + var id: Int { rawValue } + + var value: String { + switch self { + case .manualSelectManualStart: + return R.string.localizable.enumEhSettingArchiverBehaviorValueManualSelectManualStart() + case .manualSelectAutoStart: + return R.string.localizable.enumEhSettingArchiverBehaviorValueManualSelectAutoStart() + case .autoSelectOriginalManualStart: + return R.string.localizable.enumEhSettingArchiverBehaviorValueAutoSelectOriginalManualStart() + case .autoSelectOriginalAutoStart: + return R.string.localizable.enumEhSettingArchiverBehaviorValueAutoSelectOriginalAutoStart() + case .autoSelectResampleManualStart: + return R.string.localizable.enumEhSettingArchiverBehaviorValueAutoSelectResampleManualStart() + case .autoSelectResampleAutoStart: + return R.string.localizable.enumEhSettingArchiverBehaviorValueAutoSelectResampleAutoStart() + } + } +} + +// MARK: DisplayMode +extension EhSetting { + enum DisplayMode: Int, CaseIterable, Identifiable { + case compact + case thumbnail + case extended + case minimal + case minimalPlus + } +} +extension EhSetting.DisplayMode { + var id: Int { rawValue } + + var value: String { + switch self { + case .compact: + return R.string.localizable.enumEhSettingDisplayModeValueCompact() + case .thumbnail: + return R.string.localizable.enumEhSettingDisplayModeValueThumbnail() + case .extended: + return R.string.localizable.enumEhSettingDisplayModeValueExtended() + case .minimal: + return R.string.localizable.enumEhSettingDisplayModeValueMinimal() + case .minimalPlus: + return R.string.localizable.enumEhSettingDisplayModeValueMinimalPlus() + } + } +} + +// MARK: FavoritesSortOrder +extension EhSetting { + enum FavoritesSortOrder: Int, CaseIterable, Identifiable { + case lastUpdateTime + case favoritedTime + } +} +extension EhSetting.FavoritesSortOrder { + var id: Int { rawValue } + + var value: String { + switch self { + case .lastUpdateTime: + return R.string.localizable.enumEhSettingFavoritesSortOrderValueLastUpdateTime() + case .favoritedTime: + return R.string.localizable.enumEhSettingFavoritesSortOrderValueFavoritedTime() + } + } +} + +// MARK: ExcludedLanguagesCategory +extension EhSetting { + enum ExcludedLanguagesCategory: Int, Identifiable, CaseIterable { + case original + case translated + case rewrite + } +} +extension EhSetting.ExcludedLanguagesCategory { + var id: Int { rawValue } + + var value: String { + switch self { + case .original: + return R.string.localizable.enumEhSettingExcludedLanguagesCategoryValueOriginal() + case .translated: + return R.string.localizable.enumEhSettingExcludedLanguagesCategoryValueTranslated() + case .rewrite: + return R.string.localizable.enumEhSettingExcludedLanguagesCategoryValueRewrite() + } + } +} + +// MARK: SearchResultCount +extension EhSetting { + enum SearchResultCount: Int, CaseIterable, Identifiable, Comparable { + case twentyFive + case fifty + case oneHundred + case twoHundred + } +} +extension EhSetting.SearchResultCount { + var id: Int { rawValue } + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var value: String { + switch self { + case .twentyFive: + return "25" + case .fifty: + return "50" + case .oneHundred: + return "100" + case .twoHundred: + return "200" + } + } +} + +// 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 R.string.localizable.enumEhSettingThumbnailLoadTimingValueOnMouseOver() + case .onPageLoad: + return R.string.localizable.enumEhSettingThumbnailLoadTimingValueOnPageLoad() + } + } + var description: String { + switch self { + case .onMouseOver: + return R.string.localizable.enumEhSettingThumbnailLoadTimingDescriptionOnMouseOver() + case .onPageLoad: + return R.string.localizable.enumEhSettingThumbnailLoadTimingDescriptionOnPageLoad() + } + } +} + +// MARK: ThumbnailSize +extension EhSetting { + enum ThumbnailSize: Int, CaseIterable, Identifiable, Comparable { + case normal + 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 R.string.localizable.enumEhSettingThumbnailSizeValueNormal() + case .large: + return R.string.localizable.enumEhSettingThumbnailSizeValueLarge() + } + } +} + +// 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: + return "4" + case .ten: + return "10" + case .twenty: + return "20" + case .forty: + return "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 R.string.localizable.enumEhSettingCommentsSortOrderValueOldest() + case .recent: + return R.string.localizable.enumEhSettingCommentsSortOrderValueRecent() + case .highestScore: + return R.string.localizable.enumEhSettingCommentsSortOrderValueHighestScore() + } + } +} + +// 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 R.string.localizable.enumEhSettingCommentsVotesShowTimingValueOnHoverOrClick() + case .always: + return R.string.localizable.enumEhSettingCommentsVotesShowTimingValueAlways() + } + } +} + +// 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 R.string.localizable.enumEhSettingTagsSortOrderValueAlphabetical() + case .tagPower: + return R.string.localizable.enumEhSettingTagsSortOrderValueTagPower() + } + } +} + +// 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 R.string.localizable.enumEhSettingMultiplePageViewerStyleValueAlignLeftScaleIfOverWidth() + case .alignCenterScaleIfOverWidth: + return R.string.localizable.enumEhSettingMultiplePageViewerStyleValueAlignCenterScaleIfOverWidth() + case .alignCenterAlwaysScale: + return R.string.localizable.enumEhSettingMultiplePageViewerStyleValueAlignCenterAlwaysScale() + } + } +} diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift new file mode 100644 index 00000000..fd7b8b38 --- /dev/null +++ b/EhPanda/Models/Support/Misc.swift @@ -0,0 +1,49 @@ +// +// Misc.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/01/15. +// + +import Foundation +import SwiftyBeaver + +typealias Logger = SwiftyBeaver +typealias FavoritesSortOrder = EhSetting.FavoritesSortOrder + +protocol DateFormattable { + var originalDate: Date { get } +} +extension DateFormattable { + var formattedDateString: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + formatter.locale = Locale.current + formatter.calendar = Calendar.current + return formatter.string(from: originalDate) + } +} + +struct PageNumber: Equatable { + var current = 0 + var maximum = 0 + + var isSinglePage: Bool { + current == 0 && maximum == 0 + } +} + +struct QuickSearchWord: Codable, Equatable, Identifiable { + static var empty: Self { .init(name: "", content: "") } + + var id: UUID = .init() + var name: String + var content: String +} + +enum LoadingState: Equatable, Hashable { + case idle + case loading + case failed(AppError) +} diff --git a/EhPanda/Models/Support/TranslatableLanguage.swift b/EhPanda/Models/Support/TranslatableLanguage.swift new file mode 100644 index 00000000..083415f6 --- /dev/null +++ b/EhPanda/Models/Support/TranslatableLanguage.swift @@ -0,0 +1,56 @@ +// +// TranslatableLanguage.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/01. +// + +import Foundation + +enum TranslatableLanguage: Codable, CaseIterable { + case japanese + case simplifiedChinese + case traditionalChinese +} + +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 + }).first else { return nil } + return translatableLanguage + } + var languageCode: String { + switch self { + case .japanese: + return "ja" + case .simplifiedChinese: + return "zh-Hans" + case .traditionalChinese: + return "zh-Hant" + } + } + var repoName: String { + switch self { + case .japanese: + return "tatsuz0u/EhTagTranslation_Database_JPN" + case .simplifiedChinese, .traditionalChinese: + return "EhTagTranslation/Database" + } + } + var remoteFilename: String { + switch self { + case .japanese: + return "jpn_text.json" + case .simplifiedChinese, .traditionalChinese: + return "db.text.json" + } + } + var checkUpdateURL: URL { + URLUtil.githubAPI(repoName: repoName) + } + var downloadURL: URL { + URLUtil.githubDownload(repoName: repoName, fileName: remoteFilename) + } +} diff --git a/EhPanda/Models/User.swift b/EhPanda/Models/User.swift deleted file mode 100644 index 3109367c..00000000 --- a/EhPanda/Models/User.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// User.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/03. -// - -import Foundation - -struct User: Codable { - static let empty = User() - - var displayName: String? - var avatarURL: String? - var apikey: String? - - var currentGP: String? - var currentCredits: String? - - var greeting: Greeting? - - var apiuid: String { - CookiesUtil.get( - for: Defaults.URL.host.safeURL(), - key: Defaults.Cookie.ipbMemberId - ) - .rawValue - } - - var favoriteNames: [Int: String]? - - func getFavNameFrom(index: Int) -> String { - User.getFavNameFrom(index: index, names: favoriteNames) - } - - static func getFavNameFrom(index: Int, names: [Int: String]?) -> String { - var name = names?[index] ?? "Favorites \(index)" - if index == -1 { name = "all_appendedByDev" } - - let replacedName = name - .dropLast() - .replacingOccurrences( - of: "Favorites ", - with: "favoriteNameByDev" - ) - - if replacedName.hasLocalizedString { - return replacedName.localized + " \(index)" - } else { - return name.localized - } - } -} - -enum FavoritesType: String, Codable, CaseIterable { - static func getTypeFrom(index: Int) -> FavoritesType { - FavoritesType.allCases - .filter { - $0.index == index - } - .first ?? .all - } - - var index: Int { - Int(self.rawValue - .replacingOccurrences( - of: "favorite_", - with: "" - ) - ) ?? -1 - } - - case all = "all" - case favorite0 = "favorite_0" - case favorite1 = "favorite_1" - case favorite2 = "favorite_2" - case favorite3 = "favorite_3" - case favorite4 = "favorite_4" - case favorite5 = "favorite_5" - case favorite6 = "favorite_6" - case favorite7 = "favorite_7" - case favorite8 = "favorite_8" - case favorite9 = "favorite_9" -} diff --git a/EhPanda/Network/DFExtensions.swift b/EhPanda/Network/DFExtensions.swift index 9ee6cc53..7ea1fccd 100644 --- a/EhPanda/Network/DFExtensions.swift +++ b/EhPanda/Network/DFExtensions.swift @@ -24,11 +24,23 @@ private func forceDowncast(object: Any) -> T! { // MARK: URL extension URL { - func replaceHost(to newHost: String) -> URL? { - guard let originalHost = host else { return nil } - return URL(string: absoluteString.replacingOccurrences( - of: originalHost, with: newHost - )) + func modifyComponent(for url: URL, commitChanges: (inout URLComponents) -> Void) -> URL? { + guard var components = URLComponents( + url: self, resolvingAgainstBaseURL: false + ) + else { return nil } + commitChanges(&components) + return components.url + } + func replaceHost(to newHost: String?) -> URL? { + modifyComponent(for: self) { components in + components.host = newHost + } + } + func replaceScheme(to newScheme: String?) -> URL? { + modifyComponent(for: self) { components in + components.scheme = newScheme + } } } diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index 97ce5482..fb5242c1 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -6,22 +6,31 @@ // import Kanna -import OpenCC import Combine import Foundation +import ComposableArchitecture -private func mapAppError(error: Error) -> AppError { - Logger.error(error) +protocol Request { + associatedtype Response - switch error { - case is ParseError: - return .parseFailed - case is URLError: - return .networkingFailed - default: - return error as? AppError ?? .unknown + var publisher: AnyPublisher { get } +} +extension Request { + var effect: Effect, Never> { + publisher.receive(on: DispatchQueue.main).catchToEffect() + } + func mapAppError(error: Error) -> AppError { + switch error { + case is ParseError: + return .parseFailed + case is URLError: + return .networkingFailed + default: + return error as? AppError ?? .unknown + } } } + private extension Publisher { func genericRetry() -> Publishers.Retry { retry(3) @@ -46,33 +55,33 @@ private extension Dictionary where Key == String, Value == String { } // MARK: Routine -struct GreetingRequest { +struct GreetingRequest: Request { var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.greeting().safeURL()) + URLSession.shared.dataTaskPublisher(for: Defaults.URL.news) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap(Parser.parseGreeting).mapError(mapAppError).eraseToAnyPublisher() } } -struct UserInfoRequest { +struct UserInfoRequest: Request { let uid: String var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.userInfo(uid: uid).safeURL()) + URLSession.shared.dataTaskPublisher(for: URLUtil.userInfo(uid: uid)) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap(Parser.parseUserInfo).mapError(mapAppError).eraseToAnyPublisher() } } -struct FavoriteNamesRequest { +struct FavoriteCategoriesRequest: Request { var publisher: AnyPublisher<[Int: String], AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.ehConfig().safeURL()) + URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseFavoriteNames).mapError(mapAppError).eraseToAnyPublisher() + .tryMap(Parser.parseFavoriteCategories).mapError(mapAppError).eraseToAnyPublisher() } } -struct TagTranslatorRequest { +struct TagTranslatorRequest: Request { let language: TranslatableLanguage let updatedDate: Date @@ -83,12 +92,9 @@ struct TagTranslatorRequest { formatter.locale = Locale(identifier: "en_US_POSIX") return formatter } - var isChinese: Bool { - [.simplifiedChinese, .traditionalChinese].contains(language) - } var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: language.checkUpdateLink.safeURL()) + URLSession.shared.dataTaskPublisher(for: language.checkUpdateURL) .genericRetry().tryMap { data, _ -> Date in guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any], let postedDateString = dict["published_at"] as? String, @@ -100,89 +106,28 @@ struct TagTranslatorRequest { return postedDate } .flatMap { date in - URLSession.shared.dataTaskPublisher(for: language.downloadLink.safeURL()) + URLSession.shared.dataTaskPublisher(for: language.downloadURL) .tryMap { data, _ in - guard let dict = try? JSONSerialization - .jsonObject(with: data) as? [String: Any], - isChinese ? dict["version"] as? Int == 6 : true + guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw AppError.parseFailed } - let translations = parseTranslations(dict: dict) + let translations = Parser.parseTranslations(dict: dict, language: language) guard !translations.isEmpty else { throw AppError.parseFailed } - - return TagTranslator( - language: language, - updatedDate: date, - contents: translations - ) + return TagTranslator(language: language, updatedDate: date, contents: translations) } } .mapError(mapAppError).eraseToAnyPublisher() } - - func parseTranslations(dict: [String: Any]) -> [String: String] { - if isChinese { - let result = parseChineseTranslations(dict: dict) - return language != .traditionalChinese ? result - : convertToTraditionalChinese(dict: result) - } else { - return dict as? [String: String] ?? [:] - } - } - func parseChineseTranslations(dict: [String: Any]) -> [String: String] { - let categories = dict["data"] as? [[String: Any]] ?? [] - let translationsBeforeMapping = categories.compactMap { - $0["data"] as? [String: Any] - }.reduce([], +) - - var translations = [String: String]() - translationsBeforeMapping.forEach { translation in - let originalText = translation.key - let dict = translation.value as? [String: Any] - - if let translatedText = dict?["name"] as? String { - translations[originalText] = translatedText - } - } - return translations - } - func convertToTraditionalChinese(dict: [String: String]) -> [String: String] { - guard let preferredLanguage = Locale.preferredLanguages.first else { return [:] } - - var translations = [String: String]() - - var options: ChineseConverter.Options = [.traditionalize] - if preferredLanguage.contains("HK") { - options = [.traditionalize, .hkStandard] - } else if preferredLanguage.contains("TW") { - options = [.traditionalize, .twStandard, .twIdiom] - } - - guard let converter = try? ChineseConverter(options: options) - else { return [:] } - - dict.forEach { key, value in - translations[key] = converter.convert(value) - } - customConversion(dict: &translations) - - return translations - } - func customConversion(dict: inout [String: String]) { - if dict["full color"] != nil { - dict["full color"] = "全彩" - } - } } // MARK: Fetch ListItems -struct SearchItemsRequest { +struct SearchGalleriesRequest: Request { let keyword: String let filter: Filter var pageNum: Int? var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { URLSession.shared.dataTaskPublisher( - for: Defaults.URL.searchList(keyword: keyword, filter: filter, pageNum: pageNum).safeURL() + for: URLUtil.searchList(keyword: keyword, filter: filter, pageNum: pageNum) ) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } @@ -190,94 +135,99 @@ struct SearchItemsRequest { } } -struct MoreSearchItemsRequest { +struct MoreSearchGalleriesRequest: Request { let keyword: String let filter: Filter let lastID: String let pageNum: Int var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.moreSearchList( + URLSession.shared.dataTaskPublisher(for: URLUtil.moreSearchList( keyword: keyword, filter: filter, pageNum: pageNum, lastID: lastID - ).safeURL()) + )) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } .mapError(mapAppError).eraseToAnyPublisher() } } -struct FrontpageItemsRequest { +struct FrontpageGalleriesRequest: Request { let filter: Filter var pageNum: Int? var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.frontpageList(filter: filter, pageNum: pageNum).safeURL()) + URLSession.shared.dataTaskPublisher(for: URLUtil.frontpageList(filter: filter, pageNum: pageNum)) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } .mapError(mapAppError).eraseToAnyPublisher() } } -struct MoreFrontpageItemsRequest { +struct MoreFrontpageGalleriesRequest: Request { let filter: Filter let lastID: String let pageNum: Int var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.moreFrontpageList( + URLSession.shared.dataTaskPublisher(for: URLUtil.moreFrontpageList( filter: filter, pageNum: pageNum, lastID: lastID - ).safeURL()) + )) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } .mapError(mapAppError).eraseToAnyPublisher() } } -struct PopularItemsRequest { +struct PopularGalleriesRequest: Request { let filter: Filter var publisher: AnyPublisher<[Gallery], AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.popularList(filter: filter).safeURL()) + URLSession.shared.dataTaskPublisher(for: URLUtil.popularList(filter: filter)) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap(Parser.parseListItems).mapError(mapAppError).eraseToAnyPublisher() } } -struct WatchedItemsRequest { +struct WatchedGalleriesRequest: Request { let filter: Filter var pageNum: Int? + var keyword: String var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.watchedList(filter: filter, pageNum: pageNum).safeURL()) - .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } - .mapError(mapAppError).eraseToAnyPublisher() + URLSession.shared.dataTaskPublisher(for: URLUtil.watchedList( + filter: filter, pageNum: pageNum, keyword: keyword + )) + .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } + .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } + .mapError(mapAppError).eraseToAnyPublisher() } } -struct MoreWatchedItemsRequest { +struct MoreWatchedGalleriesRequest: Request { let filter: Filter let lastID: String let pageNum: Int + var keyword: String var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.moreWatchedList( - filter: filter, pageNum: pageNum, lastID: lastID - ).safeURL()) + URLSession.shared.dataTaskPublisher(for: URLUtil.moreWatchedList( + filter: filter, pageNum: pageNum, lastID: lastID, keyword: keyword + )) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } .mapError(mapAppError).eraseToAnyPublisher() } } -struct FavoritesItemsRequest { +struct FavoritesGalleriesRequest: Request { let favIndex: Int var pageNum: Int? + var keyword: String var sortOrder: FavoritesSortOrder? var publisher: AnyPublisher<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError> { URLSession.shared.dataTaskPublisher( - for: Defaults.URL.favoritesList(favIndex: favIndex, pageNum: pageNum, sortOrder: sortOrder).safeURL() + for: URLUtil.favoritesList(favIndex: favIndex, pageNum: pageNum, keyword: keyword, sortOrder: sortOrder) ) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { ( @@ -289,15 +239,16 @@ struct FavoritesItemsRequest { } } -struct MoreFavoritesItemsRequest { +struct MoreFavoritesGalleriesRequest: Request { let favIndex: Int let lastID: String let pageNum: Int + var keyword: String var publisher: AnyPublisher<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.moreFavoritesList( - favIndex: favIndex, pageNum: pageNum, lastID: lastID - ).safeURL()) + URLSession.shared.dataTaskPublisher(for: URLUtil.moreFavoritesList( + favIndex: favIndex, pageNum: pageNum, lastID: lastID, keyword: keyword + )) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { ( Parser.parsePageNum(doc: $0), @@ -308,13 +259,13 @@ struct MoreFavoritesItemsRequest { } } -struct ToplistsItemsRequest { +struct ToplistsGalleriesRequest: Request { let catIndex: Int var pageNum: Int? var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { URLSession.shared.dataTaskPublisher( - for: Defaults.URL.toplistsList(catIndex: catIndex, pageNum: pageNum).safeURL() + for: URLUtil.toplistsList(catIndex: catIndex, pageNum: pageNum) ) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } @@ -322,26 +273,27 @@ struct ToplistsItemsRequest { } } -struct MoreToplistsItemsRequest { +struct MoreToplistsGalleriesRequest: Request { let catIndex: Int let pageNum: Int var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.moreToplistsList( + URLSession.shared.dataTaskPublisher(for: URLUtil.moreToplistsList( catIndex: catIndex, pageNum: pageNum - ).safeURL()) + )) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseListItems(doc: $0)) } .mapError(mapAppError).eraseToAnyPublisher() } } -struct GalleryDetailRequest { +// MARK: Fetch others +struct GalleryDetailRequest: Request { let gid: String - let galleryURL: String + let galleryURL: URL - var publisher: AnyPublisher<(GalleryDetail, GalleryState, APIKey, Greeting?), AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.galleryDetail(url: galleryURL).safeURL()) + 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 { @@ -365,33 +317,33 @@ struct GalleryDetailRequest { } } -struct GalleryItemReverseRequest { - let url: String - let shouldParseGalleryURL: Bool +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: [], + title: detail.title, rating: detail.rating, tagStrings: [], category: detail.category, language: detail.language, uploader: detail.uploader, pageCount: detail.pageCount, postedDate: detail.postedDate, coverURL: detail.coverURL, - galleryURL: url.absoluteString + galleryURL: url ) } else { return nil } } - var publisher: AnyPublisher { + var publisher: AnyPublisher { galleryURL(url: url).genericRetry().flatMap(gallery).eraseToAnyPublisher() } - func galleryURL(url: String) -> AnyPublisher { - switch shouldParseGalleryURL { + func galleryURL(url: URL) -> AnyPublisher { + switch isGalleryImageURL { case true: - return URLSession.shared.dataTaskPublisher(for: url.safeURL()) + return URLSession.shared.dataTaskPublisher(for: url) .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap(Parser.parseGalleryURL).mapError(mapAppError) .eraseToAnyPublisher() @@ -400,12 +352,11 @@ struct GalleryItemReverseRequest { } } - func gallery(url: String) -> AnyPublisher { - URLSession.shared.dataTaskPublisher(for: url.safeURL()) + func gallery(url: URL) -> AnyPublisher { + URLSession.shared.dataTaskPublisher(for: url) .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .compactMap { - guard url.isValidURL, let url = URL(string: url), - let (detail, _) = try? Parser.parseGalleryDetail( + guard let (detail, _) = try? Parser.parseGalleryDetail( doc: $0, gid: url.pathComponents[2] ) else { return nil } @@ -416,17 +367,19 @@ struct GalleryItemReverseRequest { } } -struct GalleryArchiveRequest { - let archiveURL: String +struct GalleryArchiveRequest: Request { + let archiveURL: URL - var publisher: AnyPublisher<(GalleryArchive?, CurrentGP?, CurrentCredits?), AppError> { - URLSession.shared.dataTaskPublisher(for: archiveURL.safeURL()).genericRetry() + var publisher: AnyPublisher<(GalleryArchive, String?, String?), AppError> { + URLSession.shared.dataTaskPublisher(for: archiveURL).genericRetry() .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .map { - let archive = try? Parser.parseGalleryArchive(doc: $0) - + .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: $0) + try? Parser.parseCurrentFunds(doc: html) else { return (archive, nil, nil) } return (archive, currentGP, currentCredits) } @@ -434,187 +387,167 @@ struct GalleryArchiveRequest { } } -struct GalleryArchiveFundsRequest { +struct GalleryArchiveFundsRequest: Request { let gid: String - let galleryURL: String - - var alterGalleryURL: String { - galleryURL.replacingOccurrences( - of: Defaults.URL.exhentai, - with: Defaults.URL.ehentai - ) - } + let galleryURL: URL - var publisher: AnyPublisher<(CurrentGP, CurrentCredits)?, AppError> { - archiveURL(url: alterGalleryURL).genericRetry() + var publisher: AnyPublisher<(String, String), AppError> { + archiveURL(url: galleryURL).genericRetry() .flatMap(funds).eraseToAnyPublisher() } - func archiveURL(url: String) -> AnyPublisher { - URLSession.shared.dataTaskPublisher(for: url.safeURL()) + 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: String) -> AnyPublisher<(CurrentGP, CurrentCredits)?, AppError> { - URLSession.shared.dataTaskPublisher(for: url.safeURL()) + 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 { +struct GalleryTorrentsRequest: Request { let gid: String let token: String var publisher: AnyPublisher<[GalleryTorrent], AppError> { URLSession.shared.dataTaskPublisher( - for: Defaults.URL.galleryTorrents(gid: gid, token: token).safeURL() + for: URLUtil.galleryTorrents(gid: gid, token: token) ) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .map(Parser.parseGalleryTorrents).mapError(mapAppError).eraseToAnyPublisher() } } -struct GalleryPreviewsRequest { - let url: String +struct GalleryPreviewURLsRequest: Request { + let galleryURL: URL + let pageNum: Int - var publisher: AnyPublisher<[Int: String], AppError> { - URLSession.shared.dataTaskPublisher(for: url.safeURL()) + 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.parsePreviews).mapError(mapAppError).eraseToAnyPublisher() + .tryMap(Parser.parsePreviewURLs).mapError(mapAppError).eraseToAnyPublisher() } } -struct MPVKeysRequest { - let mpvURL: String +struct MPVKeysRequest: Request { + let mpvURL: URL var publisher: AnyPublisher<(String, [Int: String]), AppError> { - URLSession.shared.dataTaskPublisher(for: mpvURL.safeURL()) + URLSession.shared.dataTaskPublisher(for: mpvURL) .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap(Parser.parseMPVKeys).mapError(mapAppError).eraseToAnyPublisher() } } -struct ThumbnailsRequest { - let url: String +struct ThumbnailURLsRequest: Request { + let galleryURL: URL + let pageNum: Int - var publisher: AnyPublisher<[Int: String], AppError> { - URLSession.shared.dataTaskPublisher(for: url.safeURL()) + 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.parseThumbnails).mapError(mapAppError).eraseToAnyPublisher() + .tryMap(Parser.parseThumbnailURLs).mapError(mapAppError).eraseToAnyPublisher() } } -struct GalleryNormalContentsRequest { - let thumbnails: [Int: String] +struct GalleryNormalImageURLsRequest: Request { + let thumbnailURLs: [Int: URL] - var publisher: AnyPublisher<([Int: String], [Int: String]), AppError> { - thumbnails.publisher + var publisher: AnyPublisher<([Int: URL], [Int: URL]), AppError> { + thumbnailURLs.publisher .flatMap { index, url in - URLSession.shared.dataTaskPublisher(for: url.safeURL()).genericRetry() + URLSession.shared.dataTaskPublisher(for: url).genericRetry() .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { try Parser.parseGalleryNormalContent(doc: $0, index: index) } + .tryMap { try Parser.parseGalleryNormalImageURL(doc: $0, index: index) } } .collect().map { tuples in - var contents = [Int: String]() - var originalContents = [Int: String]() + var imageURLs = [Int: URL]() + var originalImageURLs = [Int: URL]() for (index, imageURL, originalImageURL) in tuples { - contents[index] = imageURL - originalContents[index] = originalImageURL + imageURLs[index] = imageURL + originalImageURLs[index] = originalImageURL } - return (contents, originalContents) + return (imageURLs, originalImageURLs) } .mapError(mapAppError).eraseToAnyPublisher() } } -struct GalleryNormalContentRefetchRequest { +struct GalleryNormalImageURLRefetchRequest: Request { let index: Int - let galleryURL: String - let thumbnailURL: String? - let storedImageURL: String - let bypassesSNIFiltering: Bool - - var publisher: AnyPublisher<[Int: String], AppError> { - storedThumbnail().flatMap(renewThumbnail).flatMap(content) - .genericRetry().map({ imageURL1, imageURL2 in - imageURL1 != storedImageURL ? imageURL1 : imageURL2 - }).map({ imageURL in [index: imageURL] }) + 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 storedThumbnail() -> AnyPublisher { + func storedThumbnailURL() -> AnyPublisher { if let thumbnailURL = thumbnailURL { - return Just(thumbnailURL).compactMap(URL.init).setFailureType(to: AppError.self).eraseToAnyPublisher() + return Just(thumbnailURL).setFailureType(to: AppError.self).eraseToAnyPublisher() } else { - return URLSession.shared.dataTaskPublisher(for: galleryURL.safeURL()) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) }.tryMap(Parser.parseThumbnails) - .compactMap({ thumbnails in URL(string: thumbnails[index] ?? "") }) + 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 renewThumbnail(stored: URL) -> AnyPublisher<(URL, String), AppError> { + func renewThumbnailURL(stored: URL) -> AnyPublisher<(URL, URL), AppError> { URLSession.shared.dataTaskPublisher(for: stored) .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } .tryMap { - try (Parser.parseRenewedThumbnail(doc: $0, stored: stored), - Parser.parseGalleryNormalContent(doc: $0, index: index).1) + try (Parser.parseRenewedThumbnailURL(doc: $0, storedThumbnailURL: stored), + Parser.parseGalleryNormalImageURL(doc: $0, index: index).1) } .mapError(mapAppError).eraseToAnyPublisher() } - func content(thumbnailURL: URL, anotherImageURL: String) -> AnyPublisher<(String, String), AppError> { + func imageURL(thumbnailURL: URL, anotherImageURL: URL) + -> AnyPublisher<(URL, URL, HTTPURLResponse?), AppError> { URLSession.shared.dataTaskPublisher(for: thumbnailURL) .tryMap { - if bypassesSNIFiltering, let (_, resp) = $0 as? (Data, HTTPURLResponse), - let setString = resp.allHeaderFields["Set-Cookie"] as? String - { - setString.components(separatedBy: ", ") - .flatMap { $0.components(separatedBy: "; ") } - .forEach { value in - let key = Defaults.Cookie.skipServer - if let range = value.range(of: "\(key)=") { - CookiesUtil.set( - for: Defaults.URL.host.safeURL(), key: key, - value: String(value[range.upperBound...]), path: "/s/", - expiresTime: TimeInterval(60 * 60 * 24 * 30) - ) - } - } - } - return try Kanna.HTML(html: $0.data, encoding: .utf8) + (try Kanna.HTML(html: $0.data, encoding: .utf8), $0.response as? HTTPURLResponse) + } + .tryMap { html, response in + (try Parser.parseGalleryNormalImageURL(doc: html, index: index), response) } - .tryMap { try Parser.parseGalleryNormalContent(doc: $0, index: index) } - .map(\.1).map({ (anotherImageURL, $0) }).mapError(mapAppError).eraseToAnyPublisher() + .map { imageURL, response in + (anotherImageURL, imageURL.1, response) + } + .mapError(mapAppError).eraseToAnyPublisher() } } -struct GalleryMPVContentRequest { +struct GalleryMPVImageURLRequest: Request { let gid: Int let index: Int let mpvKey: String - let imgKey: String - let reloadToken: ReloadToken? + let mpvImageKey: String + let reloadToken: String? - var publisher: AnyPublisher<(String, String?, ReloadToken), AppError> { - let url = Defaults.URL.ehAPI() + var publisher: AnyPublisher<(URL, URL?, String), AppError> { var params: [String: Any] = [ "method": "imagedispatch", "gid": gid, - "page": index, "imgkey": imgKey, "mpvkey": mpvKey + "page": index, "imgkey": mpvImageKey, "mpvkey": mpvKey ] if let reloadToken = reloadToken { - if let reloadToken = reloadToken as? Int { - params["nl"] = reloadToken - } else if let reloadToken = reloadToken as? String { - params["nl"] = reloadToken - } + params["nl"] = reloadToken } - var request = URLRequest(url: url.safeURL()) + var request = URLRequest(url: Defaults.URL.api) request.httpMethod = "POST" request.httpBody = try? JSONSerialization .data(withJSONObject: params, options: []) @@ -623,12 +556,16 @@ struct GalleryMPVContentRequest { .genericRetry().map(\.data).tryMap { data in guard let dict = try JSONSerialization .jsonObject(with: data) as? [String: Any], - let imageURL = dict["i"] as? String, - let reloadToken = dict["s"] + let imageURLString = dict["i"] as? String, + let imageURL = URL(string: imageURLString), + let reloadToken = dict["s"] as? String else { throw AppError.parseFailed } - if let originalImageURL = dict["lf"] as? String { - return (imageURL, Defaults.URL.host + originalImageURL, reloadToken) + if let originalImageURLStringSlice = dict["lf"] as? String { + let originalImageURL = Defaults.URL.host.appendingPathComponent( + originalImageURLStringSlice + ) + return (imageURL, originalImageURL, reloadToken) } else { return (imageURL, nil, reloadToken) } @@ -637,60 +574,66 @@ struct GalleryMPVContentRequest { } } +// 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 { +struct LoginRequest: Request { let username: String let password: String - var publisher: AnyPublisher { - let url = Defaults.URL.login + 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: url.safeURL()) + var request = URLRequest(url: Defaults.URL.login) request.httpMethod = "POST" request.httpBody = params.dictString() - .urlEncoded().data(using: .utf8) + .urlEncoded.data(using: .utf8) request.setURLEncodedContentType() - return URLSession.shared.dataTaskPublisher(for: request).genericRetry() - .map { $0 }.mapError(mapAppError).eraseToAnyPublisher() + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry().map { + $0.response as? HTTPURLResponse + } + .mapError(mapAppError).eraseToAnyPublisher() } } -struct IgneousRequest { - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.exhentai.safeURL()) - .genericRetry().map { value in - if let (_, resp) = value as? (Data, HTTPURLResponse) { - CookiesUtil.setIgneous(for: resp) - } - return value +struct IgneousRequest: Request { + var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.exhentai) + .genericRetry().compactMap { + $0.response as? HTTPURLResponse } .mapError(mapAppError).eraseToAnyPublisher() } } -struct VerifyEhProfileRequest { +struct VerifyEhProfileRequest: Request { var publisher: AnyPublisher<(Int?, Bool), AppError> { - URLSession.shared.dataTaskPublisher( - for: Defaults.URL.ehConfig().safeURL() - ) - .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseProfileIndex).mapError(mapAppError).eraseToAnyPublisher() + 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 { +struct EhProfileRequest: Request { var action: EhProfileAction? var name: String? var set: Int? var publisher: AnyPublisher { - let url = Defaults.URL.ehConfig() var params = [String: String]() if let action = action { @@ -703,10 +646,10 @@ struct EhProfileRequest { params["profile_set"] = "\(set)" } - var request = URLRequest(url: url.safeURL()) + var request = URLRequest(url: Defaults.URL.uConfig) request.httpMethod = "POST" request.httpBody = params.dictString() - .urlEncoded().data(using: .utf8) + .urlEncoded.data(using: .utf8) request.setURLEncodedContentType() return URLSession.shared.dataTaskPublisher(for: request) @@ -715,21 +658,19 @@ struct EhProfileRequest { } } -struct EhSettingRequest { +struct EhSettingRequest: Request { var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher( - for: Defaults.URL.ehConfig().safeURL() - ) - .genericRetry().tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting).mapError(mapAppError).eraseToAnyPublisher() + 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 { +struct SubmitEhSettingChangesRequest: Request { let ehSetting: EhSetting var publisher: AnyPublisher { - let url = Defaults.URL.ehConfig() + let url = Defaults.URL.uConfig var params: [String: String] = [ "uh": String(ehSetting.loadThroughHathSetting.rawValue), "co": ehSetting.browsingCountry.rawValue, @@ -754,7 +695,7 @@ struct SubmitEhSettingChangesRequest { "sc": String(ehSetting.commentVotesShowTiming.rawValue), "tb": String(ehSetting.tagsSortOrder.rawValue), "pn": ehSetting.galleryShowPageNumbers ? "1" : "0", - "hh": ehSetting.hathLocalNetworkHost, + /* "hh": ehSetting.hathLocalNetworkHost, */ "apply": "Apply" ] @@ -762,14 +703,15 @@ struct SubmitEhSettingChangesRequest { params["ct_\(name)"] = ehSetting.disabledCategories[index] ? "1" : "0" } Array(0...9).forEach { index in - params["favorite_\(index)"] = ehSetting.favoriteNames[index] + params["favorite_\(index)"] = ehSetting.favoriteCategories[index] } Array(0...10).forEach { index in params["xn_\(index + 1)"] = ehSetting.excludedNamespaces[index] ? "1" : "0" } ehSetting.excludedLanguages.enumerated().forEach { index, value in - guard value else { return } - params["xl_\(EhSetting.languageValues[index])"] = "on" + if value { + params["xl_\(EhSetting.languageValues[index])"] = "on" + } } if let useOriginalImages = ehSetting.useOriginalImages { @@ -785,10 +727,10 @@ struct SubmitEhSettingChangesRequest { params["mt"] = multiplePageViewerShowThumbnailPane ? "0" : "1" } - var request = URLRequest(url: url.safeURL()) + var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = params.dictString() - .urlEncoded().data(using: .utf8) + .urlEncoded.data(using: .utf8) request.setURLEncodedContentType() return URLSession.shared.dataTaskPublisher(for: request) @@ -797,22 +739,22 @@ struct SubmitEhSettingChangesRequest { } } -struct AddFavoriteRequest { +struct FavorGalleryRequest: Request { let gid: String let token: String let favIndex: Int var publisher: AnyPublisher { - let url = Defaults.URL.addFavorite(gid: gid, token: token) + 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.safeURL()) + var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = params.dictString() - .urlEncoded().data(using: .utf8) + .urlEncoded.data(using: .utf8) request.setURLEncodedContentType() return URLSession.shared.dataTaskPublisher(for: request) @@ -821,19 +763,18 @@ struct AddFavoriteRequest { } } -struct DeleteFavoriteRequest { +struct UnfavorGalleryRequest: Request { let gid: String var publisher: AnyPublisher { - let url = Defaults.URL.ehFavorites() let params: [String: String] = [ "ddact": "delete", "modifygids[]": gid, "apply": "Apply" ] - var request = URLRequest(url: url.safeURL()) + var request = URLRequest(url: Defaults.URL.favorites) request.httpMethod = "POST" request.httpBody = params.dictString() - .urlEncoded().data(using: .utf8) + .urlEncoded.data(using: .utf8) request.setURLEncodedContentType() return URLSession.shared.dataTaskPublisher(for: request) @@ -842,19 +783,19 @@ struct DeleteFavoriteRequest { } } -struct SendDownloadCommandRequest { - let archiveURL: String +struct SendDownloadCommandRequest: Request { + let archiveURL: URL let resolution: String - var publisher: AnyPublisher { + var publisher: AnyPublisher { let params: [String: String] = [ "hathdl_xres": resolution ] - var request = URLRequest(url: archiveURL.safeURL()) + var request = URLRequest(url: archiveURL) request.httpMethod = "POST" request.httpBody = params.dictString() - .urlEncoded().data(using: .utf8) + .urlEncoded.data(using: .utf8) request.setURLEncodedContentType() return URLSession.shared.dataTaskPublisher(for: request) @@ -863,7 +804,7 @@ struct SendDownloadCommandRequest { } } -struct RateRequest { +struct RateGalleryRequest: Request { let apiuid: Int let apikey: String let gid: Int @@ -871,14 +812,13 @@ struct RateRequest { let rating: Int var publisher: AnyPublisher { - let url = Defaults.URL.ehAPI() let params: [String: Any] = [ "method": "rategallery", "apiuid": apiuid, "apikey": apikey, "gid": gid, "token": token, "rating": rating ] - var request = URLRequest(url: url.safeURL()) + var request = URLRequest(url: Defaults.URL.api) request.httpMethod = "POST" request.httpBody = try? JSONSerialization .data(withJSONObject: params, options: []) @@ -889,18 +829,18 @@ struct RateRequest { } } -struct CommentRequest { +struct CommentGalleryRequest: Request { let content: String - let galleryURL: 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.safeURL()) + var request = URLRequest(url: galleryURL) request.httpMethod = "POST" request.httpBody = params.dictString() - .urlEncoded().data(using: .utf8) + .urlEncoded.data(using: .utf8) request.setURLEncodedContentType() return URLSession.shared.dataTaskPublisher(for: request) @@ -909,10 +849,10 @@ struct CommentRequest { } } -struct EditCommentRequest { +struct EditGalleryCommentRequest: Request { let commentID: String let content: String - let galleryURL: String + let galleryURL: URL var publisher: AnyPublisher { let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") @@ -920,10 +860,10 @@ struct EditCommentRequest { "edit_comment": commentID, "commenttext_edit": fixedContent ] - var request = URLRequest(url: galleryURL.safeURL()) + var request = URLRequest(url: galleryURL) request.httpMethod = "POST" request.httpBody = params.dictString() - .urlEncoded().data(using: .utf8) + .urlEncoded.data(using: .utf8) request.setURLEncodedContentType() return URLSession.shared.dataTaskPublisher(for: request) @@ -932,7 +872,7 @@ struct EditCommentRequest { } } -struct VoteCommentRequest { +struct VoteGalleryCommentRequest: Request { let apiuid: Int let apikey: String let gid: Int @@ -941,14 +881,13 @@ struct VoteCommentRequest { let commentVote: Int var publisher: AnyPublisher { - let url = Defaults.URL.ehAPI() let params: [String: Any] = [ "method": "votecomment", "apiuid": apiuid, "apikey": apikey, "gid": gid, "token": token, "comment_id": commentID, "comment_vote": commentVote ] - var request = URLRequest(url: url.safeURL()) + var request = URLRequest(url: Defaults.URL.api) request.httpMethod = "POST" request.httpBody = try? JSONSerialization .data(withJSONObject: params, options: []) diff --git a/EhPanda/View/Detail/ArchiveView.swift b/EhPanda/View/Detail/ArchiveView.swift deleted file mode 100644 index 9c71c1e8..00000000 --- a/EhPanda/View/Detail/ArchiveView.swift +++ /dev/null @@ -1,339 +0,0 @@ -// -// ArchiveView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/02/06. -// - -import SwiftUI -import TTProgressHUD - -struct ArchiveView: View, StoreAccessor, PersistenceAccessor { - @EnvironmentObject var store: Store - @State private var selection: ArchiveRes? - - @State private var archive: GalleryArchive? - @State private var response: String? - @State private var loadingFlag = false - @State private var loadError: AppError? - @State private var sendingFlag = false - @State private var sendFailedFlag = false - - @State private var hudVisible = false - @State private var hudConfig = TTProgressHUDConfig() - private var loadingHUDConfig = TTProgressHUDConfig( - type: .loading, title: "Communicating...".localized - ) - private let gridItems = [ - GridItem(.adaptive( - minimum: Defaults.FrameSize.archiveGridWidth, - maximum: Defaults.FrameSize.archiveGridWidth - )) - ] - - let gid: String - - init(gid: String) { - self.gid = gid - } - - // MARK: ArchiveView - var body: some View { - NavigationView { - Group { - if !hathArchives.isEmpty { - ZStack { - VStack { - gridView - Spacer() - balanceView - DownloadButton( - isDisabled: selection == nil, - action: sendDownloadCommand - ) - } - .padding(.horizontal) - TTProgressHUD($sendingFlag, config: loadingHUDConfig) - TTProgressHUD($hudVisible, config: hudConfig) - } - } else if loadingFlag { - LoadingView() - } else if let error = loadError { - ErrorView(error: error, retryAction: fetchGalleryArchive) - } else { - Circle().foregroundColor(.blue).frame(width: 1).opacity(0.1) - } - } - .navigationBarTitle("Archive") - } - .onAppear(perform: fetchGalleryArchive) - } - - // MARK: GridView - private var gridView: some View { - ScrollView(showsIndicators: false) { - LazyVGrid(columns: gridItems, spacing: 10) { - ForEach(hathArchives) { hathArchive in - ArchiveGrid( - isSelected: selection - == hathArchive.resolution, - archive: hathArchive - ) - .onTapGesture { trySelectArchive(item: hathArchive) } - } - } - .padding(.top, 40) - } - } - // MARK: BalanceView - @ViewBuilder private var balanceView: some View { - if let galleryPoints = Int(currentGP ?? ""), let credits = Int(currentCredits ?? "") { - HStack(spacing: 20) { - Label("\(galleryPoints)", systemImage: "g.circle.fill") - Label("\(credits)", systemImage: "c.circle.fill") - } - .font(.headline).lineLimit(1).padding() - } - } -} - -// MARK: Private Extension -private extension ArchiveView { - var hathArchives: [GalleryArchive.HathArchive] { - archive?.hathArchives ?? [] - } - - func trySelectArchive(item: GalleryArchive.HathArchive) { - guard item.fileSize != "N/A", item.gpPrice != "N/A" else { return } - selection = item.resolution - } - func sendDownloadCommand() { - fetchDownloadResponse() - HapticUtil.generateFeedback(style: .soft) - } - func presentHUD() { - let isSuccess = !sendFailedFlag - let type: TTProgressHUDType = isSuccess ? .success : .error - let title = (isSuccess ? "Success" : "Error").localized - let caption = response?.localized - - switch type { - case .success: - HapticUtil.generateNotificationFeedback(style: .success) - case .error: - HapticUtil.generateNotificationFeedback(style: .error) - default: - break - } - - hudConfig = TTProgressHUDConfig( - type: type, title: title, caption: caption, - shouldAutoHide: true, autoHideInterval: 1 - ) - hudVisible = true - } - - // MARK: Networking - func fetchGalleryArchive() { - loadError = nil - guard let archiveURL = galleryDetail?.archiveURL, !loadingFlag else { return } - loadingFlag = true - - let token = SubscriptionToken() - GalleryArchiveRequest(archiveURL: archiveURL) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - loadError = error - - Logger.error( - "GalleryArchiveRequest failed", - context: ["ArchiveURL": archiveURL, "Error": error] - ) - } - loadingFlag = false - token.unseal() - } receiveValue: { (archive, galleryPoints, credits) in - self.archive = archive - if let galleryPoints = galleryPoints, let credits = credits { - store.dispatch(.fetchGalleryArchiveFundsDone( - result: .success((galleryPoints, credits))) - ) - Logger.info( - "GalleryArchiveRequest succeeded", - context: [ - "ArchiveURL": archiveURL, "Archive": archive as Any, - "GalleryPoints": galleryPoints, "Credits": credits - ] - ) - } else if AuthorizationUtil.isSameAccount { - store.dispatch(.fetchGalleryArchiveFunds(gid: gid)) - } - } - .seal(in: token) - } - func fetchDownloadResponse() { - sendFailedFlag = false - guard let archiveURL = galleryDetail?.archiveURL, - let resolution = selection, !sendingFlag - else { return } - sendingFlag = true - - let token = SubscriptionToken() - SendDownloadCommandRequest( - archiveURL: archiveURL, - resolution: resolution.param - ) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - sendFailedFlag = true - - Logger.error( - "SendDownloadCommandRequest failed", - context: [ - "ArchiveURL": archiveURL, - "Resolution": resolution.param, - "Error": error - ] - ) - } - sendingFlag = false - presentHUD() - token.unseal() - } receiveValue: { resp in - switch resp { - case Defaults.Response.hathClientNotFound, - Defaults.Response.hathClientNotOnline, - Defaults.Response.invalidResolution, .none: - sendFailedFlag = true - - Logger.error( - "SendDownloadCommandRequest failed", - context: [ - "ArchiveURL": archiveURL, - "Resolution": resolution.param, - "Response": resp as Any - ] - ) - default: - Logger.info( - "SendDownloadCommandRequest succeeded", - context: [ - "ArchiveURL": archiveURL, - "Resolution": resolution.param, - "Response": resp as Any - ] - ) - } - response = resp - store.dispatch(.fetchGalleryArchiveFunds(gid: gid)) - } - .seal(in: token) - } -} - -// MARK: ArchiveGrid -private struct ArchiveGrid: View { - private var isSelected: Bool - private let archive: GalleryArchive.HathArchive - - private var disabled: Bool { - archive.fileSize == "N/A" || archive.gpPrice == "N/A" - } - private var disabledColor: Color { - .gray.opacity(0.5) - } - private var fileSizeColor: Color { - disabled ? disabledColor : .gray - } - private var borderColor: Color { - disabled ? disabledColor : isSelected ? .accentColor : .gray - } - private var environmentColor: Color? { - disabled ? disabledColor : nil - } - private var width: CGFloat { - Defaults.FrameSize.archiveGridWidth - } - private var height: CGFloat { - width / 1.5 - } - - init(isSelected: Bool, archive: GalleryArchive.HathArchive) { - self.isSelected = isSelected - self.archive = archive - } - - var body: some View { - VStack(spacing: 10) { - Text(archive.resolution.name.localized).fontWeight(.bold).font(.title3) - VStack { - Text(archive.fileSize.localized).fontWeight(.medium).font(.caption) - Text(archive.gpPrice.localized).foregroundColor(fileSizeColor).font(.caption2) - } - .lineLimit(1) - } - .foregroundColor(environmentColor).frame(width: width, height: height) - .contentShape(Rectangle()).overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(borderColor, lineWidth: 1) - ) - } -} - -// MARK: DownloadButton -private struct DownloadButton: View { - @State private var isPressed = false - - private var isDisabled: Bool - private var action: () -> Void - - init(isDisabled: Bool, action: @escaping () -> Void) { - self.isDisabled = isDisabled - self.action = action - } - - var body: some View { - HStack { - Spacer() - Text("Download To Hath Client").fontWeight(.bold) - .font(.headline).foregroundColor(textColor) - Spacer() - } - .frame(height: 50).background(backgroundColor) - .cornerRadius(30).padding(paddingInsets) - .onTapGesture { if !isDisabled { action() }} - .onLongPressGesture( - minimumDuration: 0, maximumDistance: 50, - pressing: { isPressed = $0 }, perform: {} - ) - } -} - -private extension DownloadButton { - var textColor: Color { - isDisabled ? .white.opacity(0.5) : isPressed ? .white.opacity(0.5) : .white - } - var backgroundColor: Color { - isDisabled ? .accentColor.opacity(0.5) : isPressed ? .accentColor.opacity(0.5) : .accentColor - } - var paddingInsets: EdgeInsets { - DeviceUtil.isPadWidth - ? .init(top: 0, leading: 0, bottom: 30, trailing: 0) - : .init(top: 0, leading: 10, bottom: 30, trailing: 10) - } -} - -struct ArchiveView_Previews: PreviewProvider { - static var previews: some View { - let store = Store.preview - var user = User.empty - - user.currentGP = "114" - user.currentCredits = "514" - store.appState.settings.user = user - - return ArchiveView(gid: "").environmentObject(store) - } -} diff --git a/EhPanda/View/Detail/ArchivesView.swift b/EhPanda/View/Detail/ArchivesView.swift new file mode 100644 index 00000000..0ff51e5b --- /dev/null +++ b/EhPanda/View/Detail/ArchivesView.swift @@ -0,0 +1,239 @@ +// +// ArchivesView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/02/06. +// + +import SwiftUI +import ComposableArchitecture + +struct ArchivesView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let gid: String + private let user: User + private let galleryURL: URL + private let archiveURL: URL + + init( + store: Store, + gid: String, user: User, galleryURL: URL, archiveURL: URL + ) { + self.store = store + viewStore = ViewStore(store) + self.gid = gid + self.user = user + self.galleryURL = galleryURL + self.archiveURL = archiveURL + } + + // MARK: ArchiveView + var body: some View { + NavigationView { + ZStack { + VStack { + HathArchivesView(archives: viewStore.hathArchives, selection: viewStore.binding(\.$selectedArchive)) + Spacer() + if let credits = Int(user.credits ?? ""), let galleryPoints = Int(user.galleryPoints ?? "") { + ArchiveFundsView(credits: credits, galleryPoints: galleryPoints) + } + DownloadButton(isDisabled: viewStore.selectedArchive == nil) { + viewStore.send(.fetchDownloadResponse(archiveURL)) + } + } + .padding(.horizontal).opacity(viewStore.hathArchives.isEmpty ? 0 : 1) + LoadingView().opacity( + viewStore.loadingState == .loading + && viewStore.hathArchives.isEmpty ? 1 : 0 + ) + let error = (/LoadingState.failed).extract(from: viewStore.loadingState) + ErrorView(error: error ?? .unknown) { + viewStore.send(.fetchArchive(gid, galleryURL, archiveURL)) + } + .opacity(error != nil && viewStore.hathArchives.isEmpty ? 1 : 0) + } + .progressHUD( + config: viewStore.communicatingHUDConfig, + unwrapping: viewStore.binding(\.$route), + case: /ArchivesState.Route.communicatingHUD + ) + .progressHUD( + config: viewStore.messageHUDConfig, + unwrapping: viewStore.binding(\.$route), + case: /ArchivesState.Route.messageHUD + ) + .animation(.default, value: viewStore.hathArchives) + .animation(.default, value: user.galleryPoints) + .animation(.default, value: user.credits) + .onAppear { + viewStore.send(.fetchArchive(gid, galleryURL, archiveURL)) + } + .navigationTitle(R.string.localizable.archivesViewTitleArchives()) + } + } +} + +// MARK: HathArchivesView +private struct HathArchivesView: View { + private let archives: [GalleryArchive.HathArchive] + @Binding private var selection: GalleryArchive.HathArchive? + + init(archives: [GalleryArchive.HathArchive], selection: Binding) { + self.archives = archives + _selection = selection + } + + private let gridItems = [ + GridItem(.adaptive( + minimum: Defaults.FrameSize.archiveGridWidth, + maximum: Defaults.FrameSize.archiveGridWidth + )) + ] + + var body: some View { + ScrollView(showsIndicators: false) { + LazyVGrid(columns: gridItems, spacing: 10) { + ForEach(archives) { archive in + Button { + if archive.isValid { + selection = archive + HapticUtil.generateFeedback(style: .soft) + } + } label: { + HathArchiveGrid(isSelected: selection == archive, archive: archive) + .tint(.primary).multilineTextAlignment(.center) + } + } + } + .padding(.top, 40) + } + } +} + +// MARK: ArchiveFundsView +private struct ArchiveFundsView: View { + private let credits: Int + private let galleryPoints: Int + + init(credits: Int, galleryPoints: Int) { + self.credits = credits + self.galleryPoints = galleryPoints + } + + var body: some View { + HStack(spacing: 20) { + Label("\(galleryPoints)", systemSymbol: .gCircleFill) + Label("\(credits)", systemSymbol: .cCircleFill) + } + .font(.headline).lineLimit(1).padding() + } +} + +// MARK: HathArchiveGrid +private struct HathArchiveGrid: View { + private let isSelected: Bool + private let archive: GalleryArchive.HathArchive + + private var disabledColor: Color { + .gray.opacity(0.5) + } + private var fileSizeColor: Color { + !archive.isValid ? disabledColor : .gray + } + private var borderColor: Color { + !archive.isValid ? disabledColor : isSelected ? .accentColor : .gray + } + private var foregroundColor: Color? { + !archive.isValid ? disabledColor : nil + } + private var width: CGFloat { + Defaults.FrameSize.archiveGridWidth + } + private var height: CGFloat { + width / 1.5 + } + + init(isSelected: Bool, archive: GalleryArchive.HathArchive) { + self.isSelected = isSelected + self.archive = archive + } + + var body: some View { + VStack(spacing: 10) { + Text(archive.resolution.value).font(.title3.bold()) + VStack { + Text(archive.fileSize).fontWeight(.medium).font(.caption) + Text(archive.price).foregroundColor(fileSizeColor).font(.caption2) + } + .lineLimit(1) + } + .foregroundColor(foregroundColor) + .frame(width: width, height: height) + .contentShape(Rectangle()).overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(borderColor, lineWidth: 1) + ) + } +} + +// MARK: DownloadButton +private struct DownloadButton: View { + @State private var isPressing = false + + private var isDisabled: Bool + private var action: () -> Void + + init(isDisabled: Bool, action: @escaping () -> Void) { + self.isDisabled = isDisabled + self.action = action + } + + private var textColor: Color { + isDisabled ? .white.opacity(0.5) : isPressing ? .white.opacity(0.5) : .white + } + private var backgroundColor: Color { + isDisabled ? .accentColor.opacity(0.5) : isPressing ? .accentColor.opacity(0.5) : .accentColor + } + private var paddingInsets: EdgeInsets { + DeviceUtil.isPadWidth + ? .init(top: 0, leading: 0, bottom: 30, trailing: 0) + : .init(top: 0, leading: 10, bottom: 30, trailing: 10) + } + + var body: some View { + HStack { + Spacer() + Text(R.string.localizable.archivesViewButtonDownloadToHathClient()) + .font(.headline).foregroundColor(textColor) + Spacer() + } + .frame(height: 50).background(backgroundColor) + .cornerRadius(30).padding(paddingInsets) + .onTapGesture { if !isDisabled { action() }} + .onLongPressGesture( + minimumDuration: 0, maximumDistance: 50, + pressing: { isPressing = $0 }, perform: {} + ) + } +} + +struct ArchivesView_Previews: PreviewProvider { + static var previews: some View { + ArchivesView( + store: .init( + initialState: .init(), + reducer: archivesReducer, + environment: ArchivesEnvironment( + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live + ) + ), + gid: .init(), + user: .init(), + galleryURL: .mock, + archiveURL: .mock + ) + } +} diff --git a/EhPanda/View/Detail/AssociatedView.swift b/EhPanda/View/Detail/AssociatedView.swift deleted file mode 100644 index 8b4fb076..00000000 --- a/EhPanda/View/Detail/AssociatedView.swift +++ /dev/null @@ -1,238 +0,0 @@ -// -// AssociatedView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/15. -// - -import SwiftUI -import AlertKit - -struct AssociatedView: View, StoreAccessor { - @EnvironmentObject var store: Store - @Environment(\.colorScheme) private var colorScheme - - @State private var title: String - @State private var keyword: String - - @State private var loadingFlag = false - @State private var loadError: AppError? - @State private var moreLoadingFlag = false - @State private var moreLoadFailedFlag = false - @State private var associatedItems = [Gallery]() - @State private var pageNumber = PageNumber() - - @State private var alertInput = "" - @FocusState private var isAlertFocused: Bool - @StateObject private var alertManager = CustomAlertManager() - - init(keyword: String) { - _title = State(initialValue: keyword) - _keyword = State(initialValue: keyword) - } - - // MARK: AssociatedView - var body: some View { - GenericList( - items: associatedItems, setting: setting, pageNumber: pageNumber, - loadingFlag: loadingFlag, loadError: loadError, moreLoadingFlag: moreLoadingFlag, - moreLoadFailedFlag: moreLoadFailedFlag, fetchAction: fetchAssociatedItems, - loadMoreAction: fetchMoreAssociatedItems, translateAction: translateTag - ) - .searchable( - text: $keyword, placement: .navigationBarDrawer(displayMode: .always) - ) { SuggestionProvider(keyword: $keyword) } - .toolbar(content: toolbar) - .customAlert( - manager: alertManager, widthFactor: DeviceUtil.isPadWidth ? 0.5 : 1.0, - backgroundOpacity: colorScheme == .light ? 0.2 : 0.5, - content: { - PageJumpView(inputText: $alertInput, isFocused: $isAlertFocused, pageNumber: pageNumber) - }, - buttons: [ .regular { Text("Confirm") } action: { performJumpPage()} ] - ) - .navigationBarTitle(title) - .onSubmit(of: .search, fetchAssociatedItems) - .onAppear(perform: fetchAssociatedItemsIfNeeded) - .onChange(of: pageNumber) { alertInput = String($0.current + 1) } - .onChange(of: alertManager.isPresented) { _ in isAlertFocused = false } - } - // MARK: Toolbar - private func toolbar() -> some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Button { - store.dispatch(.setHomeViewSheetState(.filter)) - } label: { - Image(systemName: "line.3.horizontal.decrease") - Text("Filters") - } - Button(action: presentJumpPageAlert) { - Image(systemName: "arrowshape.bounce.forward") - Text("Jump page") - } - .disabled(pageNumber.isSinglePage) - } label: { - Image(systemName: "ellipsis.circle") - } - } - } -} - -private extension AssociatedView { - // MARK: Tools - func translateTag(text: String) -> String { - guard setting.translatesTags else { return text } - let translator = settings.tagTranslator - - guard let range = text.range(of: ":") else { - return translator.translate(text: text) - } - - let before = text[...range.lowerBound] - let after = String(text[range.upperBound...]) - let result = before + translator.translate(text: after) - return String(result) - } - func presentJumpPageAlert() { - alertManager.show() - isAlertFocused = true - HapticUtil.generateFeedback(style: .light) - } - func performJumpPage() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard let index = Int(alertInput), index <= pageNumber.maximum + 1 else { return } - fetchAssociatedItems(pageNum: index - 1) - } - } - - func fetchAssociatedItemsIfNeeded() { - DispatchQueue.main.async { - guard associatedItems.isEmpty else { return } - fetchAssociatedItems() - } - } - func fetchAssociatedItems() { - fetchAssociatedItems(pageNum: nil) - } - - // MARK: Networking - func fetchAssociatedItems(pageNum: Int? = nil) { - if !keyword.isEmpty { - title = keyword - } - - loadError = nil - guard !loadingFlag else { return } - loadingFlag = true - - let token = SubscriptionToken() - SearchItemsRequest( - keyword: keyword.isEmpty ? title : keyword, - filter: searchFilter, pageNum: pageNum - ) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - loadingFlag = false - if case .failure(let error) = completion { - Logger.error(error) - loadError = error - - Logger.error( - "SearchItemsRequest failed", - context: [ - "Keyword": keyword.isEmpty ? title : keyword, - "Error": error - ] - ) - } - token.unseal() - } receiveValue: { pageNumber, galleries in - self.pageNumber = pageNumber - if !galleries.isEmpty { - associatedItems = galleries - - Logger.info( - "SearchItemsRequest succeeded", - context: [ - "Keyword": keyword.isEmpty ? title : keyword, "PageNumber": pageNumber, - "Galleries count": galleries.count - ] - ) - } else { - loadError = .notFound - - Logger.error( - "SearchItemsRequest failed", - context: [ - "Keyword": keyword.isEmpty ? title : keyword, - "PageNumber": pageNumber, "Error": loadError as Any - ] - ) - } - PersistenceController.add(galleries: galleries) - - if galleries.isEmpty && pageNumber.current < pageNumber.maximum { - fetchMoreAssociatedItems() - } - } - .seal(in: token) - } - func fetchMoreAssociatedItems() { - moreLoadFailedFlag = false - guard let lastID = associatedItems.last?.id, - pageNumber.current + 1 <= pageNumber.maximum, - !moreLoadingFlag else { return } - moreLoadingFlag = true - - let token = SubscriptionToken() - MoreSearchItemsRequest( - keyword: keyword.isEmpty ? title : keyword, filter: searchFilter, - lastID: lastID, pageNum: pageNumber.current + 1 - ) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - moreLoadingFlag = false - if case .failure(let error) = completion { - moreLoadFailedFlag = true - Logger.error(error) - - Logger.error( - "MoreSearchItemsRequest failed", - context: [ - "Keyword": keyword, "LastID": lastID, - "PageNumber": pageNumber, "Error": error - ] - ) - } - token.unseal() - } receiveValue: { pageNumber, galleries in - self.pageNumber = pageNumber - - if associatedItems.isEmpty { - associatedItems = galleries - } else { - associatedItems.append( - contentsOf: galleries.filter({ - !associatedItems.contains($0) - }) - ) - } - PersistenceController.add(galleries: galleries) - - Logger.info( - "MoreSearchItemsRequest succeeded", - context: [ - "Keyword": keyword, "LastID": lastID, "PageNumber": pageNumber, - "Galleries count": galleries.count - ] - ) - - if galleries.isEmpty && pageNumber.current < pageNumber.maximum { - fetchMoreAssociatedItems() - Logger.warning("MoreSearchItemsRequest result empty, requesting more...") - } - } - .seal(in: token) - } -} diff --git a/EhPanda/View/Detail/CommentView.swift b/EhPanda/View/Detail/CommentView.swift deleted file mode 100644 index d4d4ea6e..00000000 --- a/EhPanda/View/Detail/CommentView.swift +++ /dev/null @@ -1,324 +0,0 @@ -// -// CommentView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/02. -// - -import SwiftUI -import Kingfisher -import TTProgressHUD - -struct CommentView: View, StoreAccessor { - @EnvironmentObject var store: Store - - @State private var commentContent = "" - @State private var editCommentContent = "" - @State private var editCommentID = "" - @State private var commentJumpID: String? - @State private var isNavLinkActive = false - - @State private var hudVisible = false - @State private var hudConfig = TTProgressHUDConfig() - - @State private var commentCellOpacity: Double = 1 - - private let gid: String - private let comments: [GalleryComment] - private var scrollID: String? - - init(gid: String, comments: [GalleryComment], scrollID: String? = nil) { - self.gid = gid - self.comments = comments - self.scrollID = scrollID - } - - // MARK: CommentView - var body: some View { - ZStack { - ScrollViewReader { proxy in - List(comments) { comment in - CommentCell(gid: gid, comment: comment, linkAction: handleURL) - .opacity(comment.commentID == scrollID ? commentCellOpacity : 1) - .onAppear { - if comment.commentID == scrollID { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - withAnimation { commentCellOpacity = 0.25 } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.25) { - withAnimation { commentCellOpacity = 1 } - } - } - } - .swipeActions(edge: .leading) { leadingSwipeActions(comment: comment) } - .swipeActions(edge: .trailing) { trailingSwipeActions(comment: comment) } - } - .onAppear { - guard let id = scrollID else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - withAnimation { proxy.scrollTo(id) } - } - } - } - TTProgressHUD($hudVisible, config: hudConfig) - } - .background { - NavigationLink("", destination: DetailView(gid: commentJumpID ?? gid), isActive: $isNavLinkActive) - } - .toolbar(content: toolbar).sheet(item: $store.appState.environment.commentViewSheetState, content: sheet) - .onChange(of: environment.galleryItemReverseLoading) { if !$0 { dismissHUD() } } - .onChange(of: environment.galleryItemReverseID, perform: tryActivateNavLink) - .onAppear { replaceGalleryCommentJumpID(gid: nil) } - } - // MARK: LeadingSwipeActions - @ViewBuilder private func leadingSwipeActions(comment: GalleryComment) -> some View { - if comment.votable { - Button { - voteDownComment(comment) - } label: { - Image(systemName: "hand.thumbsdown") - } - .tint(.red) - } - } - // MARK: TrailingSwipeActions - @ViewBuilder private func trailingSwipeActions(comment: GalleryComment) -> some View { - if comment.votable { - Button { - voteUpComment(comment) - } label: { - Image(systemName: "hand.thumbsup") - } - .tint(.green) - } - if comment.editable { - Button { - editComment(comment) - } label: { - Image(systemName: "square.and.pencil") - } - } - } - // MARK: Toolbar - private func toolbar() -> some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - store.dispatch(.setCommentViewSheetState(.newComment)) - } label: { - Image(systemName: "square.and.pencil") - } - .disabled(!AuthorizationUtil.didLogin) - } - } - // MARK: Sheet - private func sheet(item: CommentViewSheetState) -> some View { - Group { - switch item { - case .newComment: - DraftCommentView( - content: $commentContent, title: "Post Comment", - postAction: postNewComment, cancelAction: toggleCommentViewSheetNil - ) - case .editComment: - DraftCommentView( - content: $editCommentContent, title: "Edit Comment", - postAction: postEditComment, cancelAction: toggleCommentViewSheetNil - ) - } - } - .accentColor(accentColor) - .blur(radius: environment.blurRadius) - .allowsHitTesting(environment.isAppUnlocked) - } -} - -// MARK: Private Extension -private extension CommentView { - func handleURL(_ url: URL) { - URLUtil.handleURL(url, handlesOutgoingURL: true) - { shouldParseGalleryURL, incomingURL, pageIndex, commentID in - guard let incomingURL = incomingURL else { return } - - let gid = URLUtil.parseGID(url: incomingURL, isGalleryURL: shouldParseGalleryURL) - store.dispatch(.setPendingJumpInfos( - gid: gid, pageIndex: pageIndex, commentID: commentID - )) - - if PersistenceController.galleryCached(gid: gid) { - replaceGalleryCommentJumpID(gid: gid) - } else { - store.dispatch(.fetchGalleryItemReverse( - url: incomingURL.absoluteString, - shouldParseGalleryURL: shouldParseGalleryURL - )) - presentHUD() - } - } - } - func tryActivateNavLink(newValue: String?) { - guard newValue != nil else { return } - - commentJumpID = newValue - isNavLinkActive = true - replaceGalleryCommentJumpID(gid: nil) - } - - func presentHUD() { - hudConfig = TTProgressHUDConfig(type: .loading, title: "Loading...".localized) - hudVisible = true - } - func dismissHUD() { - hudVisible = false - hudConfig = TTProgressHUDConfig() - } - - func voteUpComment(_ comment: GalleryComment) { - store.dispatch(.voteGalleryComment(gid: gid, commentID: comment.commentID, vote: 1)) - } - func voteDownComment(_ comment: GalleryComment) { - store.dispatch(.voteGalleryComment(gid: gid, commentID: comment.commentID, vote: -1)) - } - func editComment(_ comment: GalleryComment) { - editCommentID = comment.commentID - editCommentContent = comment.contents - .filter { [.plainText, .linkedText, .singleLink].contains($0.type) } - .compactMap { $0.type == .singleLink ? $0.link : $0.text }.joined() - store.dispatch(.setCommentViewSheetState(.editComment)) - } - func postNewComment() { - store.dispatch(.commentGallery(gid: gid, content: commentContent)) - toggleCommentViewSheetNil() - commentContent = "" - } - func postEditComment() { - store.dispatch(.editGalleryComment(gid: gid, commentID: editCommentID, content: editCommentContent)) - editCommentID = "" - editCommentContent = "" - toggleCommentViewSheetNil() - } - - func replaceGalleryCommentJumpID(gid: String?) { - store.dispatch(.setGalleryCommentJumpID(gid: gid)) - } - func toggleCommentViewSheetNil() { - store.dispatch(.setCommentViewSheetState(nil)) - } -} - -// MARK: CommentCell -private struct CommentCell: View { - private let gid: String - private var comment: GalleryComment - private let linkAction: (URL) -> Void - - init(gid: String, comment: GalleryComment, linkAction: @escaping (URL) -> Void) { - self.gid = gid - self.comment = comment - self.linkAction = linkAction - } - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text(comment.author).fontWeight(.bold).font(.subheadline) - Spacer() - Group { - ZStack { - Image(systemName: "hand.thumbsup.fill") - .opacity(comment.votedUp ? 1 : 0) - Image(systemName: "hand.thumbsdown.fill") - .opacity(comment.votedDown ? 1 : 0) - } - Text(comment.score ?? "") - Text(comment.formattedDateString) - } - .font(.footnote).foregroundStyle(.secondary) - } - .minimumScaleFactor(0.75).lineLimit(1) - ForEach(comment.contents) { content in - switch content.type { - case .plainText: - if let text = content.text { - LinkedText(text: text, action: linkAction) - } - case .linkedText: - if let text = content.text, let link = content.link { - Text(text).foregroundStyle(.tint) - .onTapGesture { linkAction(link.safeURL()) } - } - case .singleLink: - if let link = content.link { - Text(link).foregroundStyle(.tint) - .onTapGesture { linkAction(link.safeURL()) } - } - case .singleImg, .doubleImg, .linkedImg, .doubleLinkedImg: - generateWebImages( - imgURL: content.imgURL, secondImgURL: content.secondImgURL, - link: content.link, secondLink: content.secondLink - ) - } - } - .fixedSize(horizontal: false, vertical: true) - } - .padding() - } - - @ViewBuilder - private func generateWebImages( - imgURL: String?, secondImgURL: String?, - link: String?, secondLink: String? - ) -> some View { - // Double - if let imgURL = imgURL, let secondImgURL = secondImgURL { - HStack(spacing: 0) { - if let link = link, let secondLink = secondLink { - KFImage(URL(string: imgURL)) - .commentDefaultModifier().scaledToFit() - .frame(width: DeviceUtil.windowW / 4) - .onTapGesture { linkAction(link.safeURL()) } - KFImage(URL(string: secondImgURL)) - .commentDefaultModifier().scaledToFit() - .frame(width: DeviceUtil.windowW / 4) - .onTapGesture { linkAction(secondLink.safeURL()) } - } else { - KFImage(URL(string: imgURL)) - .commentDefaultModifier().scaledToFit() - .frame(width: DeviceUtil.windowW / 4) - KFImage(URL(string: secondImgURL)) - .commentDefaultModifier().scaledToFit() - .frame(width: DeviceUtil.windowW / 4) - } - } - } - // Single - else if let imgURL = imgURL { - if let link = link { - KFImage(URL(string: imgURL)) - .commentDefaultModifier().scaledToFit() - .frame(width: DeviceUtil.windowW / 2) - .onTapGesture { linkAction(link.safeURL()) } - } else { - KFImage(URL(string: imgURL)) - .commentDefaultModifier().scaledToFit() - .frame(width: DeviceUtil.windowW / 2) - } - } - } -} - -private extension KFImage { - func commentDefaultModifier() -> KFImage { - defaultModifier() - .placeholder { - Placeholder(style: .activity(ratio: 1)) - } - } -} - -// MARK: Definition -enum CommentViewSheetState: Identifiable { - var id: Int { hashValue } - - case newComment - case editComment -} diff --git a/EhPanda/View/Detail/CommentsView.swift b/EhPanda/View/Detail/CommentsView.swift new file mode 100644 index 00000000..06ed345f --- /dev/null +++ b/EhPanda/View/Detail/CommentsView.swift @@ -0,0 +1,303 @@ +// +// CommentsView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/01/02. +// + +import SwiftUI +import Kingfisher +import ComposableArchitecture + +struct CommentsView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let gid: String + private let token: String + private let apiKey: String + private let galleryURL: URL + private let comments: [GalleryComment] + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + gid: String, token: String, apiKey: String, galleryURL: URL, + comments: [GalleryComment], user: User, setting: Binding, + blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.gid = gid + self.token = token + self.apiKey = apiKey + self.galleryURL = galleryURL + self.comments = comments + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + // MARK: CommentView + var body: some View { + ScrollViewReader { proxy in + List(comments) { comment in + CommentCell( + gid: gid, comment: comment, + linkAction: { viewStore.send(.handleCommentLink($0)) } + ) + .opacity( + comment.commentID == viewStore.scrollCommentID + ? viewStore.scrollRowOpacity : 1 + ) + .swipeActions(edge: .leading) { + if comment.votable { + Button { + viewStore.send(.voteComment(gid, token, apiKey, comment.commentID, -1)) + } label: { + Image(systemSymbol: .handThumbsdown) + } + .tint(.red) + } + } + .swipeActions(edge: .trailing) { + if comment.votable { + Button { + viewStore.send(.voteComment(gid, token, apiKey, comment.commentID, 1)) + } label: { + Image(systemSymbol: .handThumbsup) + } + .tint(.green) + } + if comment.editable { + Button { + viewStore.send(.setCommentContent(comment.plainTextContent)) + viewStore.send(.setNavigation(.postComment(comment.commentID))) + } label: { + Image(systemSymbol: .squareAndPencil) + } + } + } + } + .onAppear { + if let scrollCommentID = viewStore.scrollCommentID { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { + withAnimation { + proxy.scrollTo(scrollCommentID, anchor: .top) + } + } + } + } + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /CommentsState.Route.postComment) { route in + let hasCommentID = !route.wrappedValue.isEmpty + PostCommentView( + title: hasCommentID + ? R.string.localizable.postCommentViewTitleEditComment() + : R.string.localizable.postCommentViewTitlePostComment(), + content: viewStore.binding(\.$commentContent), + isFocused: viewStore.binding(\.$postCommentFocused), + postAction: { + if hasCommentID { + viewStore.send(.postComment(galleryURL, route.wrappedValue)) + } else { + viewStore.send(.postComment(galleryURL)) + } + viewStore.send(.setNavigation(nil)) + }, + cancelAction: { viewStore.send(.setNavigation(nil)) }, + onAppearAction: { viewStore.send(.onPostCommentAppear) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .progressHUD( + config: viewStore.hudConfig, + unwrapping: viewStore.binding(\.$route), + case: /CommentsState.Route.hud + ) + .animation(.default, value: viewStore.scrollRowOpacity) + .onAppear { + viewStore.send(.onAppear) + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.commentsViewTitleComments()) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Button { + viewStore.send(.setNavigation(.postComment(""))) + } label: { + Image(systemSymbol: .squareAndPencil) + } + .disabled(!CookiesUtil.didLogin) + } + } +} + +// MARK: NavigationLinks +private extension CommentsView { + @ViewBuilder var navigationLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /CommentsState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: CommentsAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } +} + +// MARK: CommentCell +private struct CommentCell: View { + private let gid: String + private var comment: GalleryComment + private let linkAction: (URL) -> Void + + init(gid: String, comment: GalleryComment, linkAction: @escaping (URL) -> Void) { + self.gid = gid + self.comment = comment + self.linkAction = linkAction + } + + 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) + } + .font(.footnote).foregroundStyle(.secondary) + } + .minimumScaleFactor(0.75).lineLimit(1) + ForEach(comment.contents) { content in + switch content.type { + case .plainText: + if let text = content.text { + LinkedText(text: text, action: linkAction) + } + case .linkedText: + if let text = content.text, let link = content.link { + Text(text).foregroundStyle(.tint) + .onTapGesture { linkAction(link) } + } + case .singleLink: + if let link = content.link { + Text(link.absoluteString).foregroundStyle(.tint) + .onTapGesture { linkAction(link) } + } + case .singleImg, .doubleImg, .linkedImg, .doubleLinkedImg: + generateWebImages( + imgURL: content.imgURL, secondImgURL: content.secondImgURL, + link: content.link, secondLink: content.secondLink + ) + } + } + .fixedSize(horizontal: false, vertical: true) + } + .padding() + } + + @ViewBuilder private func generateWebImages( + imgURL: URL?, secondImgURL: URL?, + link: URL?, secondLink: URL? + ) -> some View { + // Double + if let imgURL = imgURL, let secondImgURL = secondImgURL { + HStack(spacing: 0) { + if let link = link, let secondLink = secondLink { + imageContainer(url: imgURL, widthFactor: 4) { + linkAction(link) + } + imageContainer(url: secondImgURL, widthFactor: 4) { + linkAction(secondLink) + } + } else { + imageContainer(url: imgURL, widthFactor: 4) + imageContainer(url: secondImgURL, widthFactor: 4) + } + } + } + // Single + else if let imgURL = imgURL { + if let link = link { + imageContainer(url: imgURL, widthFactor: 2) { + linkAction(link) + } + } else { + imageContainer(url: imgURL, widthFactor: 2) + } + } + } + @ViewBuilder func imageContainer( + url: URL, widthFactor: Double, action: (() -> Void)? = nil + ) -> some View { + let image = KFImage(url) + .commentDefaultModifier().scaledToFit() + .frame(width: DeviceUtil.windowW / widthFactor) + if let action = action { + Button(action: action) { + image + } + .buttonStyle(.plain) + } else { + image + } + } +} + +private extension KFImage { + func commentDefaultModifier() -> KFImage { + defaultModifier() + .placeholder { + Placeholder(style: .activity(ratio: 1)) + } + } +} + +struct CommentsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + CommentsView( + store: .init( + initialState: .init(), + reducer: commentsReducer, + environment: CommentsEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + gid: .init(), + token: .init(), + apiKey: .init(), + galleryURL: .mock, + comments: [], + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } + } +} diff --git a/EhPanda/View/Detail/DataFlow/ArchivesStore.swift b/EhPanda/View/Detail/DataFlow/ArchivesStore.swift new file mode 100644 index 00000000..0d8b67ae --- /dev/null +++ b/EhPanda/View/Detail/DataFlow/ArchivesStore.swift @@ -0,0 +1,138 @@ +// +// ArchivesStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/19. +// + +import TTProgressHUD +import ComposableArchitecture + +struct ArchivesState: Equatable { + enum Route { + case messageHUD + case communicatingHUD + } + struct CancelID: Hashable { + let id = String(describing: ArchivesState.self) + } + + @BindableState var route: Route? + @BindableState var selectedArchive: GalleryArchive.HathArchive? + + var loadingState: LoadingState = .idle + var hathArchives = [GalleryArchive.HathArchive]() + + var messageHUDConfig = TTProgressHUDConfig() + var communicatingHUDConfig: TTProgressHUDConfig = .communicating +} + +enum ArchivesAction: BindableAction { + case binding(BindingAction) + case setNavigation(ArchivesState.Route?) + + case syncGalleryFunds(String, String) + + case teardown + case fetchArchive(String, URL, URL) + case fetchArchiveDone(String, URL, Result<(GalleryArchive, String?, String?), AppError>) + case fetchArchiveFunds(String, URL) + case fetchArchiveFundsDone(Result<(String, String), AppError>) + case fetchDownloadResponse(URL) + case fetchDownloadResponseDone(Result) +} + +struct ArchivesEnvironment { + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient +} + +let archivesReducer = Reducer { state, action, environment in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .syncGalleryFunds(let galleryPoints, let credits): + return environment.databaseClient + .updateGalleryFunds(galleryPoints: galleryPoints, credits: credits).fireAndForget() + + case .teardown: + return .cancel(id: ArchivesState.CancelID()) + + case .fetchArchive(let gid, let galleryURL, let archiveURL): + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return GalleryArchiveRequest(archiveURL: archiveURL) + .effect.map({ ArchivesAction.fetchArchiveDone(gid, galleryURL, $0) }) + .cancellable(id: ArchivesState.CancelID()) + + case .fetchArchiveDone(let gid, let galleryURL, let result): + state.loadingState = .idle + switch result { + case .success(let (archive, galleryPoints, credits)): + guard !archive.hathArchives.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.hathArchives = archive.hathArchives + if let galleryPoints = galleryPoints, let credits = credits { + return .init(value: .syncGalleryFunds(galleryPoints, credits)) + } else if environment.cookiesClient.isSameAccount { + return .init(value: .fetchArchiveFunds(gid, galleryURL)) + } else { + return .none + } + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchArchiveFunds(let gid, let galleryURL): + guard let galleryURL = galleryURL.replaceHost(to: Defaults.URL.ehentai.host) else { return .none } + return GalleryArchiveFundsRequest(gid: gid, galleryURL: galleryURL) + .effect.map(ArchivesAction.fetchArchiveFundsDone).cancellable(id: ArchivesState.CancelID()) + + case .fetchArchiveFundsDone(let result): + if case .success(let (galleryPoints, credits)) = result { + return .init(value: .syncGalleryFunds(galleryPoints, credits)) + } + return .none + + case .fetchDownloadResponse(let archiveURL): + guard let selectedArchive = state.selectedArchive, state.route != .communicatingHUD else { return .none } + state.route = .communicatingHUD + return SendDownloadCommandRequest(archiveURL: archiveURL, resolution: selectedArchive.resolution.parameter) + .effect.map(ArchivesAction.fetchDownloadResponseDone).cancellable(id: ArchivesState.CancelID()) + + case .fetchDownloadResponseDone(let result): + state.route = .messageHUD + let isSuccess: Bool + switch result { + case .success(let response): + switch response { + case R.string.constant.websiteResponseHathClientNotFound(): + state.messageHUDConfig = .error(caption: R.string.localizable.hathDownloadResponseHathClientNotFound()) + isSuccess = false + case R.string.constant.websiteResponseHathClientNotOnline(): + state.messageHUDConfig = .error(caption: R.string.localizable.hathDownloadResponseHathClientNotOnline()) + isSuccess = false + case R.string.constant.websiteResponseInvalidResolution(): + state.messageHUDConfig = .error(caption: R.string.localizable.hathDownloadResponseInvalidResolution()) + isSuccess = false + default: + state.messageHUDConfig = .success(caption: response) + isSuccess = true + } + case .failure: + state.messageHUDConfig = .error + isSuccess = false + } + return environment.hapticClient.generateNotificationFeedback(isSuccess ? .success : .error).fireAndForget() + } +} +.binding() diff --git a/EhPanda/View/Detail/DataFlow/CommentsStore.swift b/EhPanda/View/Detail/DataFlow/CommentsStore.swift new file mode 100644 index 00000000..e1e94597 --- /dev/null +++ b/EhPanda/View/Detail/DataFlow/CommentsStore.swift @@ -0,0 +1,226 @@ +// +// CommentsStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/16. +// + +import TTProgressHUD +import ComposableArchitecture + +struct CommentsState: Equatable { + enum Route: Equatable { + case hud + case detail(String) + case postComment(String) + } + struct CancelID: Hashable { + let id = String(describing: CommentsState.self) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var commentContent = "" + @BindableState var postCommentFocused = false + + var hudConfig: TTProgressHUDConfig = .loading + var scrollCommentID: String? + var scrollRowOpacity: Double = 1 + + @Heap var detailState: DetailState! +} + +enum CommentsAction: BindableAction { + case binding(BindingAction) + case setNavigation(CommentsState.Route?) + case clearSubStates + case clearScrollCommentID + + case setHUDConfig(TTProgressHUDConfig) + case setPostCommentFocused(Bool) + case setScrollRowOpacity(Double) + case setCommentContent(String) + case performScrollOpacityEffect + case handleCommentLink(URL) + case handleGalleryLink(URL) + case onPostCommentAppear + case onAppear + + case updateReadingProgress(String, Int) + + case teardown + case postComment(URL, String? = nil) + case voteComment(String, String, String, String, Int) + case performCommentActionDone(Result) + case fetchGallery(URL, Bool) + case fetchGalleryDone(URL, Result) + + case detail(DetailAction) +} + +struct CommentsEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let commentsReducer = Reducer { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.commentContent = .init() + state.postCommentFocused = false + return .init(value: .detail(.teardown)) + + case .clearScrollCommentID: + state.scrollCommentID = nil + return .none + + case .setHUDConfig(let config): + state.hudConfig = config + return .none + + case .setPostCommentFocused(let isFocused): + state.postCommentFocused = isFocused + return .none + + case .setScrollRowOpacity(let opacity): + state.scrollRowOpacity = opacity + return .none + + case .setCommentContent(let content): + state.commentContent = content + return .none + + case .performScrollOpacityEffect: + return .merge( + .init(value: .setScrollRowOpacity(0.25)) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect(), + .init(value: .setScrollRowOpacity(1)) + .delay(for: .milliseconds(1250), scheduler: DispatchQueue.main).eraseToEffect(), + .init(value: .clearScrollCommentID) + .delay(for: .milliseconds(2000), scheduler: DispatchQueue.main).eraseToEffect() + ) + + case .handleCommentLink(let url): + guard environment.urlClient.checkIfHandleable(url) else { + return environment.uiApplicationClient.openURL(url).fireAndForget() + } + let (isGalleryImageURL, _, _) = environment.urlClient.analyzeURL(url) + let gid = environment.urlClient.parseGalleryID(url) + guard environment.databaseClient.fetchGallery(gid: gid) == nil else { + return .init(value: .handleGalleryLink(url)) + } + return .init(value: .fetchGallery(url, isGalleryImageURL)) + + case .handleGalleryLink(let url): + let (_, pageIndex, commentID) = environment.urlClient.analyzeURL(url) + let gid = environment.urlClient.parseGalleryID(url) + var effects = [Effect]() + if let pageIndex = pageIndex { + effects.append(.init(value: .updateReadingProgress(gid, pageIndex))) + effects.append( + .init(value: .detail(.setNavigation(.reading))) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + ) + } else if let commentID = commentID { + state.detailState.commentsState?.scrollCommentID = commentID + effects.append( + .init(value: .detail(.setNavigation(.comments(url)))) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + ) + } + effects.append(.init(value: .setNavigation(.detail(gid)))) + return .merge(effects) + + case .onPostCommentAppear: + return .init(value: .setPostCommentFocused(true)) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + + case .onAppear: + if state.detailState == nil { + state.detailState = .init() + } + return state.scrollCommentID != nil ? .init(value: .performScrollOpacityEffect) : .none + + case .updateReadingProgress(let gid, let progress): + guard !gid.isEmpty else { return .none } + return environment.databaseClient + .updateReadingProgress(gid: gid, progress: progress).fireAndForget() + + case .teardown: + return .cancel(id: CommentsState.CancelID()) + + case .postComment(let galleryURL, let commentID): + guard !state.commentContent.isEmpty else { return .none } + if let commentID = commentID { + return EditGalleryCommentRequest( + commentID: commentID, content: state.commentContent, galleryURL: galleryURL + ) + .effect.map(CommentsAction.performCommentActionDone).cancellable(id: CommentsState.CancelID()) + } else { + return CommentGalleryRequest(content: state.commentContent, galleryURL: galleryURL) + .effect.map(CommentsAction.performCommentActionDone).cancellable(id: CommentsState.CancelID()) + } + + case .voteComment(let gid, let token, let apiKey, let commentID, let vote): + guard let gid = Int(gid), let commentID = Int(commentID), + let apiuid = Int(environment.cookiesClient.apiuid) + else { return .none } + return VoteGalleryCommentRequest( + apiuid: apiuid, apikey: apiKey, gid: gid, token: token, + commentID: commentID, commentVote: vote + ) + .effect.map(CommentsAction.performCommentActionDone).cancellable(id: CommentsState.CancelID()) + + case .performCommentActionDone: + return .none + + case .fetchGallery(let url, let isGalleryImageURL): + state.route = .hud + return GalleryReverseRequest(url: url, isGalleryImageURL: isGalleryImageURL) + .effect.map({ CommentsAction.fetchGalleryDone(url, $0) }).cancellable(id: CommentsState.CancelID()) + + case .fetchGalleryDone(let url, let result): + state.route = nil + switch result { + case .success(let gallery): + return .merge( + environment.databaseClient.cacheGalleries([gallery]).fireAndForget(), + .init(value: .handleGalleryLink(url)) + ) + case .failure: + return .init(value: .setHUDConfig(.error)) + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + } + + case .detail: + return .none + } +} +.haptics( + unwrapping: \.route, + case: /CommentsState.Route.postComment, + hapticClient: \.hapticClient +) +.binding() diff --git a/EhPanda/View/Detail/DataFlow/DetailSearchStore.swift b/EhPanda/View/Detail/DataFlow/DetailSearchStore.swift new file mode 100644 index 00000000..8822ca4f --- /dev/null +++ b/EhPanda/View/Detail/DataFlow/DetailSearchStore.swift @@ -0,0 +1,237 @@ +// +// SearchStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/12. +// + +import ComposableArchitecture + +struct DetailSearchState: Equatable { + enum Route: Equatable { + case filters + case quickSearch + case detail(String) + } + struct CancelID: Hashable { + let id = String(describing: DetailSearchState.self) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + var lastKeyword = "" + @BindableState var jumpPageIndex = "" + @BindableState var jumpPageAlertFocused = false + @BindableState var jumpPageAlertPresented = false + + var galleries = [Gallery]() + var pageNumber = PageNumber() + var loadingState: LoadingState = .idle + var footerLoadingState: LoadingState = .idle + + @Heap var detailState: DetailState! + var filtersState = FiltersState() + var quickDetailSearchState = QuickSearchState() + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } +} + +enum DetailSearchAction: BindableAction { + case binding(BindingAction) + case setNavigation(DetailSearchState.Route?) + case clearSubStates + + case performJumpPage + case presentJumpPageAlert + case setJumpPageAlertFocused(Bool) + + case teardown + case fetchGalleries(Int? = nil, String? = nil) + case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + + case detail(DetailAction) + case filters(FiltersAction) + case quickSearch(QuickSearchAction) +} + +struct DetailSearchEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let detailSearchReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$jumpPageAlertPresented): + if !state.jumpPageAlertPresented { + state.jumpPageAlertFocused = false + } + return .none + + case .binding(\.$keyword): + if !state.keyword.isEmpty { + state.lastKeyword = state.keyword + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + state.quickDetailSearchState = .init() + return .merge( + .init(value: .detail(.teardown)), + .init(value: .quickSearch(.teardown)) + ) + + case .performJumpPage: + guard let index = Int(state.jumpPageIndex), index > 0, index <= state.pageNumber.maximum + 1 else { + return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() + } + return .init(value: .fetchGalleries(index - 1)) + + case .presentJumpPageAlert: + state.jumpPageAlertPresented = true + return environment.hapticClient.generateFeedback(.light).fireAndForget() + + case .setJumpPageAlertFocused(let isFocused): + state.jumpPageAlertFocused = isFocused + return .none + + case .teardown: + return .cancel(id: DetailSearchState.CancelID()) + + case .fetchGalleries(let pageNum, let keyword): + guard state.loadingState != .loading else { return .none } + if let keyword = keyword { + state.keyword = keyword + state.lastKeyword = keyword + } + state.loadingState = .loading + state.pageNumber.current = 0 + let filter = environment.databaseClient.fetchFilterSynchronously(range: .search) + return SearchGalleriesRequest(keyword: state.lastKeyword, filter: filter, pageNum: pageNum) + .effect.map(DetailSearchAction.fetchGalleriesDone).cancellable(id: DetailSearchState.CancelID()) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + guard pageNumber.current < pageNumber.maximum else { + state.loadingState = .failed(.notFound) + return .none + } + return .init(value: .fetchMoreGalleries) + } + state.pageNumber = pageNumber + state.galleries = galleries + return environment.databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.current + 1 <= pageNumber.maximum, + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let pageNum = pageNumber.current + 1 + let filter = environment.databaseClient.fetchFilterSynchronously(range: .search) + return MoreSearchGalleriesRequest( + keyword: state.lastKeyword, filter: filter, lastID: lastID, pageNum: pageNum + ) + .effect.map(DetailSearchAction.fetchMoreGalleriesDone).cancellable(id: DetailSearchState.CancelID()) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.pageNumber = pageNumber + state.insertGalleries(galleries) + + var effects: [Effect] = [ + environment.databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.current < pageNumber.maximum { + effects.append(.init(value: .fetchMoreGalleries)) + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .detail: + return .none + + case .filters: + return .none + + case .quickSearch: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /DetailSearchState.Route.quickSearch, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /DetailSearchState.Route.filters, + hapticClient: \.hapticClient + ) + .binding(), + filtersReducer.pullback( + state: \.filtersState, + action: /DetailSearchAction.filters, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + quickSearchReducer.pullback( + state: \.quickDetailSearchState, + action: /DetailSearchAction.quickSearch, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ) +) diff --git a/EhPanda/View/Detail/DataFlow/DetailStore.swift b/EhPanda/View/Detail/DataFlow/DetailStore.swift new file mode 100644 index 00000000..8bb68083 --- /dev/null +++ b/EhPanda/View/Detail/DataFlow/DetailStore.swift @@ -0,0 +1,499 @@ +// +// DetailStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/10. +// + +import SwiftUI +import ComposableArchitecture + +struct DetailState: Equatable { + enum Route: Equatable { + case reading + case archives(URL, URL) + case torrents + case previews + case comments(URL) + case share(URL) + case postComment + case newDawn(Greeting) + case detailSearch(String) + case galleryInfos(Gallery, GalleryDetail) + } + struct CancelID: Hashable { + let id = String(describing: DetailState.self) + } + + init() { + _commentsState = .init(nil) + _detailSearchState = .init(nil) + } + + @BindableState var route: Route? + @BindableState var commentContent = "" + @BindableState var postCommentFocused = false + + var showsNewDawnGreeting = false + var showsUserRating = false + var showsFullTitle = false + var userRating = 0 + + var apiKey = "" + var loadingState: LoadingState = .idle + var gallery: Gallery = .empty + var galleryDetail: GalleryDetail? + var galleryTags = [GalleryTag]() + var galleryPreviewURLs = [Int: URL]() + var galleryComments = [GalleryComment]() + + var readingState = ReadingState(gallery: .empty) + var archivesState = ArchivesState() + var torrentsState = TorrentsState() + var previewsState = PreviewsState(gallery: .empty) + @Heap var commentsState: CommentsState? + var galleryInfosState = GalleryInfosState() + @Heap var detailSearchState: DetailSearchState? + + mutating func updateRating(value: DragGesture.Value) { + let rating = Int(value.location.x / 31 * 2) + 1 + userRating = min(max(rating, 1), 10) + } +} + +indirect enum DetailAction: BindableAction { + case binding(BindingAction) + case setNavigation(DetailState.Route?) + case setupPreviewsState + case setupReadingState + case clearSubStates + case onPostCommentAppear + case onAppear(String, Bool) + + case toggleShowFullTitle + case toggleShowUserRating + case setCommentContent(String) + case setPostCommentFocused(Bool) + case updateRating(DragGesture.Value) + case confirmRating(DragGesture.Value) + case confirmRatingDone + + case syncGalleryTags + case syncGalleryDetail + case syncGalleryPreviewURLs + case syncGalleryComments + case syncGreeting(Greeting) + case syncPreviewConfig(PreviewConfig) + case saveGalleryHistory + case updateReadingProgress(Int) + + case teardown + case fetchDatabaseInfos(String) + case fetchDatabaseInfosDone(GalleryState) + case fetchGalleryDetail + case fetchGalleryDetailDone(Result<(GalleryDetail, GalleryState, String, Greeting?), AppError>) + + case rateGallery + case favorGallery(Int) + case unfavorGallery + case postComment(URL) + case anyGalleryOpsDone(Result) + + case reading(ReadingAction) + case archives(ArchivesAction) + case torrents(TorrentsAction) + case previews(PreviewsAction) + case comments(CommentsAction) + case galleryInfos(GalleryInfosAction) + case detailSearch(DetailSearchAction) +} + +struct DetailEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let detailReducer = Reducer.recurse { (self) in + Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .setupPreviewsState: + state.previewsState = .init(gallery: state.gallery) + return .none + + case .setupReadingState: + state.readingState = .init(gallery: state.gallery) + return .none + + case .clearSubStates: + state.archivesState = .init() + state.torrentsState = .init() + state.commentsState = .init() + state.commentContent = .init() + state.postCommentFocused = false + state.galleryInfosState = .init() + state.detailSearchState = .init() + return .merge( + .init(value: .setupPreviewsState), + .init(value: .setupReadingState), + .init(value: .reading(.teardown)), + .init(value: .archives(.teardown)), + .init(value: .torrents(.teardown)), + .init(value: .previews(.teardown)), + .init(value: .comments(.teardown)), + .init(value: .detailSearch(.teardown)) + ) + + case .onPostCommentAppear: + return .init(value: .setPostCommentFocused(true)) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + + case .onAppear(let gid, let showsNewDawnGreeting): + state.showsNewDawnGreeting = showsNewDawnGreeting + if state.detailSearchState == nil { + state.detailSearchState = .init() + } + if state.commentsState == nil { + state.commentsState = .init() + } + return .init(value: .fetchDatabaseInfos(gid)) + + case .toggleShowFullTitle: + state.showsFullTitle.toggle() + return environment.hapticClient.generateFeedback(.soft).fireAndForget() + + case .toggleShowUserRating: + state.showsUserRating.toggle() + return environment.hapticClient.generateFeedback(.soft).fireAndForget() + + 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( + .init(value: .rateGallery), + environment.hapticClient.generateFeedback(.soft).fireAndForget(), + .init(value: .confirmRatingDone).delay(for: 1, scheduler: DispatchQueue.main).eraseToEffect() + ) + + case .confirmRatingDone: + state.showsUserRating = false + return .none + + case .syncGalleryTags: + return environment.databaseClient + .updateGalleryTags(gid: state.gallery.id, tags: state.galleryTags).fireAndForget() + + case .syncGalleryDetail: + guard let detail = state.galleryDetail else { return .none } + return environment.databaseClient.cacheGalleryDetail(detail).fireAndForget() + + case .syncGalleryPreviewURLs: + return environment.databaseClient + .updatePreviewURLs(gid: state.gallery.id, previewURLs: state.galleryPreviewURLs).fireAndForget() + + case .syncGalleryComments: + return environment.databaseClient + .updateComments(gid: state.gallery.id, comments: state.galleryComments).fireAndForget() + + case .syncGreeting(let greeting): + return environment.databaseClient.updateGreeting(greeting).fireAndForget() + + case .syncPreviewConfig(let config): + return environment.databaseClient + .updatePreviewConfig(gid: state.gallery.id, config: config).fireAndForget() + + case .saveGalleryHistory: + return environment.databaseClient.updateLastOpenDate(gid: state.gallery.id).fireAndForget() + + case .updateReadingProgress(let progress): + return environment.databaseClient + .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() + + case .teardown: + return .cancel(id: DetailState.CancelID()) + + case .fetchDatabaseInfos(let gid): + guard let gallery = environment.databaseClient.fetchGallery(gid: gid) else { return .none } + state.gallery = gallery + if let detail = environment.databaseClient.fetchGalleryDetail(gid: gid) { + state.galleryDetail = detail + } + return .merge( + .init(value: .saveGalleryHistory), + environment.databaseClient.fetchGalleryState(gid: state.gallery.id) + .map(DetailAction.fetchDatabaseInfosDone).cancellable(id: DetailState.CancelID()) + ) + + case .fetchDatabaseInfosDone(let galleryState): + state.galleryTags = galleryState.tags + state.galleryPreviewURLs = galleryState.previewURLs + state.galleryComments = galleryState.comments + return .merge( + .init(value: .fetchGalleryDetail), + .init(value: .setupPreviewsState), + .init(value: .setupReadingState) + ) + + case .fetchGalleryDetail: + guard state.loadingState != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.loadingState = .loading + return GalleryDetailRequest(gid: state.gallery.id, galleryURL: galleryURL) + .effect.map(DetailAction.fetchGalleryDetailDone).cancellable(id: DetailState.CancelID()) + + case .fetchGalleryDetailDone(let result): + state.loadingState = .idle + switch result { + case .success(let (galleryDetail, galleryState, apiKey, greeting)): + var effects: [Effect] = [ + .init(value: .syncGalleryTags), + .init(value: .syncGalleryDetail), + .init(value: .syncGalleryPreviewURLs), + .init(value: .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(.init(value: .syncGreeting(greeting))) + if !greeting.gainedNothing && state.showsNewDawnGreeting { + effects.append(.init(value: .setNavigation(.newDawn(greeting)))) + } + } + if let config = galleryState.previewConfig { + effects.append(.init(value: .syncPreviewConfig(config))) + } + return .merge(effects) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .rateGallery: + guard let apiuid = Int(environment.cookiesClient.apiuid), + let gid = Int(state.gallery.id) + else { return .none } + return RateGalleryRequest( + apiuid: apiuid, apikey: state.apiKey, gid: gid, + token: state.gallery.token, rating: state.userRating + ) + .effect.map(DetailAction.anyGalleryOpsDone).cancellable(id: DetailState.CancelID()) + + case .favorGallery(let favIndex): + return FavorGalleryRequest(gid: state.gallery.id, token: state.gallery.token, favIndex: favIndex) + .effect.map(DetailAction.anyGalleryOpsDone).cancellable(id: DetailState.CancelID()) + + case .unfavorGallery: + return UnfavorGalleryRequest(gid: state.gallery.id).effect.map(DetailAction.anyGalleryOpsDone) + .cancellable(id: DetailState.CancelID()) + + case .postComment(let galleryURL): + guard !state.commentContent.isEmpty else { return .none } + return CommentGalleryRequest(content: state.commentContent, galleryURL: galleryURL) + .effect.map(DetailAction.anyGalleryOpsDone).cancellable(id: DetailState.CancelID()) + + case .anyGalleryOpsDone(let result): + if case .success = result { + return .merge( + .init(value: .fetchGalleryDetail), + environment.hapticClient.generateNotificationFeedback(.success).fireAndForget() + ) + } + return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() + + case .reading: + return .none + + case .archives: + return .none + + case .torrents: + return .none + + case .previews: + return .none + + case .comments(.performCommentActionDone(let result)): + return .init(value: .anyGalleryOpsDone(result)) + + case .comments(.detail(let recursiveAction)): + guard state.commentsState != nil else { return .none } + return self.run(&state.commentsState!.detailState, recursiveAction, environment) + .map({ DetailAction.comments(.detail($0)) }) + + case .comments: + return .none + + case .galleryInfos: + return .none + + case .detailSearch(.detail(let recursiveAction)): + guard state.detailSearchState != nil else { return .none } + return self.run(&state.detailSearchState!.detailState, recursiveAction, environment) + .map({ DetailAction.detailSearch(.detail($0)) }) + + case .detailSearch: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /DetailState.Route.postComment, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /DetailState.Route.torrents, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /DetailState.Route.archives, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /DetailState.Route.reading, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /DetailState.Route.share, + hapticClient: \.hapticClient + ) + .binding(), + readingReducer.pullback( + state: \.readingState, + action: /DetailAction.reading, + environment: { + .init( + urlClient: $0.urlClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient + ) + } + ), + archivesReducer.pullback( + state: \.archivesState, + action: /DetailAction.archives, + environment: { + .init( + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient + ) + } + ), + torrentsReducer.pullback( + state: \.torrentsState, + action: /DetailAction.torrents, + environment: { + .init( + fileClient: $0.fileClient, + hapticClient: $0.hapticClient, + clipboardClient: $0.clipboardClient + ) + } + ), + previewsReducer.pullback( + state: \.previewsState, + action: /DetailAction.previews, + environment: { + .init( + urlClient: $0.urlClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient + ) + } + ), + commentsReducer.optional().pullback( + state: \.commentsState, + action: /DetailAction.comments, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + detailSearchReducer.optional().pullback( + state: \.detailSearchState, + action: /DetailAction.detailSearch, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + galleryInfosReducer.pullback( + state: \.galleryInfosState, + action: /DetailAction.galleryInfos, + environment: { + .init( + hapticClient: $0.hapticClient, + clipboardClient: $0.clipboardClient + ) + } + ) + ) +} diff --git a/EhPanda/View/Detail/DataFlow/GalleryInfosStore.swift b/EhPanda/View/Detail/DataFlow/GalleryInfosStore.swift new file mode 100644 index 00000000..d6ae0812 --- /dev/null +++ b/EhPanda/View/Detail/DataFlow/GalleryInfosStore.swift @@ -0,0 +1,44 @@ +// +// GalleryInfosStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/23. +// + +import TTProgressHUD +import ComposableArchitecture + +struct GalleryInfosState: Equatable { + enum Route { + case hud + } + + @BindableState var route: Route? + var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded +} + +enum GalleryInfosAction: BindableAction { + case binding(BindingAction) + case copyText(String) +} + +struct GalleryInfosEnvironment { + let hapticClient: HapticClient + let clipboardClient: ClipboardClient +} + +let galleryInfosReducer = Reducer +{ state, action, environment in + switch action { + case .binding: + return .none + + case .copyText(let text): + state.route = .hud + return .merge( + environment.clipboardClient.saveText(text).fireAndForget(), + environment.hapticClient.generateNotificationFeedback(.success).fireAndForget() + ) + } +} +.binding() diff --git a/EhPanda/View/Detail/DataFlow/PreviewsStore.swift b/EhPanda/View/Detail/DataFlow/PreviewsStore.swift new file mode 100644 index 00000000..ee160795 --- /dev/null +++ b/EhPanda/View/Detail/DataFlow/PreviewsStore.swift @@ -0,0 +1,162 @@ +// +// PreviewsStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/16. +// + +import ComposableArchitecture + +struct PreviewsState: Equatable { + enum Route { + case reading + } + struct CancelID: Hashable { + let id = String(describing: PreviewsState.self) + } + + @BindableState var route: Route? + let gallery: Gallery + + var loadingState: LoadingState = .idle + var databaseLoadingState: LoadingState = .loading + + var previewURLs = [Int: URL]() + var previewConfig: PreviewConfig = .normal(rows: 4) + + var readingState = ReadingState(gallery: .empty) + + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { + self.previewURLs = self.previewURLs.merging( + previewURLs, uniquingKeysWith: { stored, _ in stored } + ) + } +} + +enum PreviewsAction: BindableAction { + case binding(BindingAction) + case setNavigation(PreviewsState.Route?) + case clearSubStates + case setupReadingState + + case syncPreviewURLs([Int: URL]) + case updateReadingProgress(Int) + + case teardown + case fetchDatabaseInfos + case fetchDatabaseInfosDone(GalleryState) + case fetchPreviewURLs(Int) + case fetchPreviewURLsDone(Result<[Int: URL], AppError>) + + case reading(ReadingAction) +} + +struct PreviewsEnvironment { + let urlClient: URLClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient +} + +let previewsReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + return .merge( + .init(value: .setupReadingState), + .init(value: .reading(.teardown)) + ) + + case .setupReadingState: + state.readingState = .init(gallery: state.gallery) + return .none + + case .syncPreviewURLs(let previewURLs): + return environment.databaseClient + .updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs).fireAndForget() + + case .updateReadingProgress(let progress): + return environment.databaseClient + .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() + + case .teardown: + return .cancel(id: PreviewsState.CancelID()) + + case .fetchDatabaseInfos: + return environment.databaseClient.fetchGalleryState(gid: state.gallery.id) + .map(PreviewsAction.fetchDatabaseInfosDone).cancellable(id: PreviewsState.CancelID()) + + case .fetchDatabaseInfosDone(let galleryState): + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + state.previewURLs = galleryState.previewURLs + state.databaseLoadingState = .idle + return .init(value: .setupReadingState) + + case .fetchPreviewURLs(let index): + guard state.loadingState != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.loadingState = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum) + .effect.map(PreviewsAction.fetchPreviewURLsDone).cancellable(id: PreviewsState.CancelID()) + + case .fetchPreviewURLsDone(let result): + state.loadingState = .idle + + switch result { + case .success(let previewURLs): + guard !previewURLs.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.updatePreviewURLs(previewURLs) + return .init(value: .syncPreviewURLs(previewURLs)) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .reading: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /PreviewsState.Route.reading, + hapticClient: \.hapticClient + ) + .binding(), + readingReducer.pullback( + state: \.readingState, + action: /PreviewsAction.reading, + environment: { + .init( + urlClient: $0.urlClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient + ) + } + ) +) diff --git a/EhPanda/View/Detail/DataFlow/TorrentsStore.swift b/EhPanda/View/Detail/DataFlow/TorrentsStore.swift new file mode 100644 index 00000000..b80547f8 --- /dev/null +++ b/EhPanda/View/Detail/DataFlow/TorrentsStore.swift @@ -0,0 +1,108 @@ +// +// TorrentsStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/19. +// + +import Foundation +import TTProgressHUD +import ComposableArchitecture + +struct TorrentsState: Equatable { + enum Route: Equatable { + case hud + case share(URL) + } + struct CancelID: Hashable { + let id = String(describing: TorrentsState.self) + } + + @BindableState var route: Route? + var torrents = [GalleryTorrent]() + var loadingState: LoadingState = .idle + var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded +} + +enum TorrentsAction: BindableAction { + case binding(BindingAction) + case setNavigation(TorrentsState.Route) + + case copyText(String) + case presentTorrentActivity(String, Data) + + case teardown + case fetchTorrent(String, URL) + case fetchTorrentDone(String, Result) + case fetchGalleryTorrents(String, String) + case fetchGalleryTorrentsDone(Result<[GalleryTorrent], AppError>) +} + +struct TorrentsEnvironment { + let fileClient: FileClient + let hapticClient: HapticClient + let clipboardClient: ClipboardClient +} + +let torrentsReducer = Reducer { state, action, environment in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .copyText(let magnetURL): + state.route = .hud + return .merge( + environment.clipboardClient.saveText(magnetURL).fireAndForget(), + environment.hapticClient.generateNotificationFeedback(.success).fireAndForget() + ) + + case .presentTorrentActivity(let hash, let data): + if let url = environment.fileClient.saveTorrent(hash: hash, data: data) { + return .init(value: .setNavigation(.share(url))) + } + return .none + + case .fetchTorrent(let hash, let torrentURL): + return DataRequest(url: torrentURL).effect.map({ TorrentsAction.fetchTorrentDone(hash, $0) }) + .cancellable(id: TorrentsState.CancelID()) + + case .teardown: + return .cancel(id: TorrentsState.CancelID()) + + case .fetchTorrentDone(let hash, let result): + if case .success(let data) = result, !data.isEmpty { + return .init(value: .presentTorrentActivity(hash, data)) + } + return .none + + case .fetchGalleryTorrents(let gid, let token): + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return GalleryTorrentsRequest(gid: gid, token: token) + .effect.map(TorrentsAction.fetchGalleryTorrentsDone).cancellable(id: TorrentsState.CancelID()) + + case .fetchGalleryTorrentsDone(let result): + state.loadingState = .idle + switch result { + case .success(let torrents): + guard !torrents.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.torrents = torrents + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + } +} +.haptics( + unwrapping: \.route, + case: /TorrentsState.Route.share, + hapticClient: \.hapticClient +) +.binding() diff --git a/EhPanda/View/Detail/DetailSearchView.swift b/EhPanda/View/Detail/DetailSearchView.swift new file mode 100644 index 00000000..ac891890 --- /dev/null +++ b/EhPanda/View/Detail/DetailSearchView.swift @@ -0,0 +1,157 @@ +// +// DetailSearchView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/12. +// + +import SwiftUI +import ComposableArchitecture + +struct DetailSearchView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let keyword: String + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.keyword = keyword + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + GenericList( + galleries: viewStore.galleries, + setting: setting, + pageNumber: viewStore.pageNumber, + loadingState: viewStore.loadingState, + footerLoadingState: viewStore.footerLoadingState, + fetchAction: { viewStore.send(.fetchGalleries()) }, + fetchMoreAction: { viewStore.send(.fetchMoreGalleries) }, + navigateAction: { viewStore.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: !setting.translatesTags) + } + ) + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /DetailSearchState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: DetailSearchAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailSearchState.Route.quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickDetailSearchState, action: DetailSearchAction.quickSearch) + ) { keyword in + viewStore.send(.setNavigation(nil)) + viewStore.send(.fetchGalleries(nil, keyword)) + } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailSearchState.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: DetailSearchAction.filters)) + .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + } + .jumpPageAlert( + index: viewStore.binding(\.$jumpPageIndex), + isPresented: viewStore.binding(\.$jumpPageAlertPresented), + isFocused: viewStore.binding(\.$jumpPageAlertFocused), + pageNumber: viewStore.pageNumber, + jumpAction: { viewStore.send(.performJumpPage) } + ) + .animation(.default, value: viewStore.jumpPageAlertPresented) + .searchable(text: viewStore.binding(\.$keyword)) + .onSubmit(of: .search) { + viewStore.send(.fetchGalleries()) + } + .onAppear { + if viewStore.galleries.isEmpty { + DispatchQueue.main.async { + viewStore.send(.fetchGalleries(nil, keyword)) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(viewStore.lastKeyword) + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailSearchState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: DetailSearchAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + } + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(disabled: viewStore.jumpPageAlertPresented) { + ToolbarFeaturesMenu { + FiltersButton { + viewStore.send(.setNavigation(.filters)) + } + QuickSearchButton { + viewStore.send(.setNavigation(.quickSearch)) + } + JumpPageButton(pageNumber: viewStore.pageNumber) { + viewStore.send(.presentJumpPageAlert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + viewStore.send(.setJumpPageAlertFocused(true)) + } + } + } + } + } +} + +struct DetailSearchView_Previews: PreviewProvider { + static var previews: some View { + DetailSearchView( + store: .init( + initialState: .init(), + reducer: detailSearchReducer, + environment: DetailSearchEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + keyword: .init(), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } +} diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index c6bbda23..4caf2fd1 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -2,398 +2,405 @@ // DetailView.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/05. +// Created by 荒木辰造 on R 4/01/10. // import SwiftUI import Kingfisher +import ComposableArchitecture -struct DetailView: View, StoreAccessor, PersistenceAccessor { - @EnvironmentObject var store: Store +struct DetailView: View { @Environment(\.colorScheme) private var colorScheme + @Environment(\.inSheet) private var inSheet - @State private var keyword = "" - @State private var commentContent = "" - @State private var commentViewScrollID = "" - @State private var isReadingLinkActive = false - @State private var isCommentsLinkActive = false - @State private var isTorrentsLinkActive = false - @State private var isAssociatedLinkActive = false - - let gid: String + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let gid: String + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator - init(gid: String) { + init( + store: Store, gid: String, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) self.gid = gid + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + private var commentsBackgroundColor: Color { + inSheet && colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6) } - // MARK: DetailView var body: some View { ZStack { - if let detail = galleryDetail { - ScrollView(showsIndicators: false) { - VStack(spacing: 30) { - HeaderView( - gallery: gallery, detail: detail, - favoriteNames: user.favoriteNames, - addFavAction: { store.dispatch(.favorGallery(gid: gid, favIndex: $0)) }, - deleteFavAction: { store.dispatch(.unfavorGallery(gid: gid)) }, - onUploaderTapAction: tryNavigateToUploader - ) - .padding(.horizontal) - DescScrollView(gallery: gallery, detail: detail) - ActionRow( - detail: detail, - ratingAction: rateGallery, - galleryAction: tryNavigateToSimilarGallery - ) - if !galleryState.tags.isEmpty { - TagsView( - tags: galleryState.tags, - onTapAction: navigateToAssociatedView, - translateAction: tryTranslateTag - ) - .padding(.horizontal) + ScrollView(showsIndicators: false) { + VStack(spacing: 30) { + HeaderSection( + gallery: viewStore.gallery, + galleryDetail: viewStore.galleryDetail ?? .empty, + user: user, + showFullTitle: viewStore.showsFullTitle, + showFullTitleAction: { viewStore.send(.toggleShowFullTitle) }, + favorAction: { viewStore.send(.favorGallery($0)) }, + unfavorAction: { viewStore.send(.unfavorGallery) }, + navigateReadingAction: { viewStore.send(.setNavigation(.reading)) }, + navigateUploaderAction: { + if let uploader = viewStore.galleryDetail?.uploader { + let keyword = "uploader:" + "\"\(uploader)\"" + viewStore.send(.setNavigation(.detailSearch(keyword))) + } + } + ) + .padding(.horizontal) + DescriptionSection( + gallery: viewStore.gallery, + galleryDetail: viewStore.galleryDetail ?? .empty, + navigateGalleryInfosAction: { + if let galleryDetail = viewStore.galleryDetail { + viewStore.send(.setNavigation(.galleryInfos(viewStore.gallery, galleryDetail))) + } + } + ) + ActionSection( + galleryDetail: viewStore.galleryDetail ?? .empty, + userRating: viewStore.userRating, + showUserRating: viewStore.showsUserRating, + showUserRatingAction: { viewStore.send(.toggleShowUserRating) }, + updateRatingAction: { viewStore.send(.updateRating($0)) }, + confirmRatingAction: { viewStore.send(.confirmRating($0)) }, + navigateSimilarGalleryAction: { + if let trimmedTitle = viewStore.galleryDetail?.trimmedTitle { + viewStore.send(.setNavigation(.detailSearch(trimmedTitle))) + } } - PreviewView( - gid: gid, previews: detailInfo.previews[gid] ?? [:], - pageCount: detail.pageCount, tapAction: navigateToReading, - fetchAction: { store.dispatch(.fetchGalleryPreviews(gid: gid, index: $0)) } + ) + if !viewStore.galleryTags.isEmpty { + TagsSection( + tags: viewStore.galleryTags, + navigateAction: { + viewStore.send(.setNavigation(.detailSearch($0))) + }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: !setting.translatesTags) + } ) - CommentScrollView( - gid: gid, comments: galleryState.comments, - toggleCommentAction: { presentSheet(state: .comment) } + .padding(.horizontal) + } + if !viewStore.galleryPreviewURLs.isEmpty { + PreviewsSection( + pageCount: viewStore.galleryDetail?.pageCount ?? 0, + previewURLs: viewStore.galleryPreviewURLs, + navigatePreviewsAction: { viewStore.send(.setNavigation(.previews)) }, + navigateReadingAction: { + viewStore.send(.updateReadingProgress($0)) + viewStore.send(.setNavigation(.reading)) + } ) } - .padding(.bottom, 20) - .padding(.top, -25) - } - .transition(AppUtil.opacityTransition) - } else if detailInfo.detailLoading[gid] == true { - LoadingView() - } else if let error = detailInfo.detailLoadErrors[gid] { - switch error { - case .copyrightClaim, .expunged: - ErrorView(error: error) - default: - ErrorView(error: error, retryAction: fetchGalleryDetail) + CommentsSection( + comments: viewStore.galleryComments, backgroundColor: commentsBackgroundColor, + navigateCommentAction: { + if let galleryURL = viewStore.gallery.galleryURL { + viewStore.send(.setNavigation(.comments(galleryURL))) + } + }, + navigatePostCommentAction: { viewStore.send(.setNavigation(.postComment)) } + ) } + .padding(.bottom, 20) + .padding(.top, -25) } + .opacity(viewStore.galleryDetail == nil ? 0 : 1) + LoadingView() + .opacity( + viewStore.galleryDetail == nil + && viewStore.loadingState == .loading ? 1 : 0 + ) + let error = (/LoadingState.failed).extract(from: viewStore.loadingState) + let retryAction = { viewStore.send(.fetchGalleryDetail) } + ErrorView(error: error ?? .unknown, action: error?.isRetryable != false ? retryAction : nil) + .opacity(viewStore.galleryDetail == nil && error != nil ? 1 : 0) } - .onAppear(perform: onStartTasks).onDisappear(perform: onEndTasks) - .navigationBarHidden(environment.navigationBarHidden) - .sheet(item: $store.appState.environment.detailViewSheetState, content: sheet) - .background(content: navigationLinks).toolbar(content: toolbar) + .fullScreenCover(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.reading) { _ in + ReadingView( + store: store.scope(state: \.readingState, action: DetailAction.reading), + setting: $setting, blurRadius: blurRadius, + dismissAction: { viewStore.send(.setNavigation(nil)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.archives) { route in + let (galleryURL, archiveURL) = route.wrappedValue + ArchivesView( + store: store.scope(state: \.archivesState, action: DetailAction.archives), + gid: gid, user: user, galleryURL: galleryURL, archiveURL: archiveURL + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.torrents) { _ in + TorrentsView( + store: store.scope(state: \.torrentsState, action: DetailAction.torrents), + gid: gid, token: viewStore.gallery.token, blurRadius: blurRadius + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.share) { route in + ActivityView(activityItems: [route.wrappedValue]) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.postComment) { _ in + PostCommentView( + title: R.string.localizable.postCommentViewTitlePostComment(), + content: viewStore.binding(\.$commentContent), + isFocused: viewStore.binding(\.$postCommentFocused), + postAction: { + if let galleryURL = viewStore.gallery.galleryURL { + viewStore.send(.postComment(galleryURL)) + } + viewStore.send(.setNavigation(nil)) + }, + cancelAction: { viewStore.send(.setNavigation(nil)) }, + onAppearAction: { viewStore.send(.onPostCommentAppear) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.newDawn) { route in + NewDawnView(greeting: route.wrappedValue) + .autoBlur(radius: blurRadius) + } + .animation(.default, value: viewStore.showsUserRating) + .animation(.default, value: viewStore.showsFullTitle) + .animation(.default, value: viewStore.galleryDetail) + .onAppear { + DispatchQueue.main.async { + viewStore.send(.onAppear(gid, setting.showsNewDawnGreeting)) + } + } + .background(navigationLinks) + .toolbar(content: toolbar) } } +// MARK: NavigationLinks private extension DetailView { - // MARK: NavigationLinks - @ViewBuilder func navigationLinks() -> some View { - NavigationLink("", destination: ReadingView(gid: gid), isActive: $isReadingLinkActive) - NavigationLink( - "", destination: CommentView(gid: gid, comments: galleryState.comments, scrollID: commentViewScrollID), - isActive: $isCommentsLinkActive - ) - NavigationLink("", destination: TorrentsView(gid: gid, token: gallery.token), isActive: $isTorrentsLinkActive) - NavigationLink("", destination: AssociatedView(keyword: keyword), isActive: $isAssociatedLinkActive) - } - // MARK: Sheet - func sheet(item: DetailViewSheetState) -> some View { - Group { - switch item { - case .archive: - ArchiveView(gid: gid) - case .comment: - DraftCommentView( - content: $commentContent, - title: "Post Comment", - postAction: postComment, - cancelAction: { presentSheet(state: nil) } + @ViewBuilder var navigationLinks: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.previews) { _ in + PreviewsView( + store: store.scope(state: \.previewsState, action: DetailAction.previews), + setting: $setting, blurRadius: blurRadius + ) + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.comments) { route in + IfLetStore(store.scope(state: \.commentsState, action: DetailAction.comments)) { store in + CommentsView( + store: store, gid: gid, token: viewStore.gallery.token, apiKey: viewStore.apiKey, + galleryURL: route.wrappedValue, comments: viewStore.galleryComments, user: user, + setting: $setting, blurRadius: blurRadius, + tagTranslator: tagTranslator ) } } - .accentColor(accentColor) - .blur(radius: environment.blurRadius) - .allowsHitTesting(environment.isAppUnlocked) + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.detailSearch) { route in + IfLetStore(store.scope(state: \.detailSearchState, action: DetailAction.detailSearch)) { store in + DetailSearchView( + store: store, keyword: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.galleryInfos) { route in + let (gallery, galleryDetail) = route.wrappedValue + GalleryInfosView( + store: store.scope(state: \.galleryInfosState, action: DetailAction.galleryInfos), + gallery: gallery, galleryDetail: galleryDetail + ) + } } - // MARK: Toolbar +} + +// MARK: ToolBar +private extension DetailView { func toolbar() -> some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - Menu { + CustomToolbarItem { + ToolbarFeaturesMenu { Button { - presentSheet(state: .archive) + if let galleryURL = viewStore.gallery.galleryURL, + let archiveURL = viewStore.galleryDetail?.archiveURL + { + viewStore.send(.setNavigation(.archives(galleryURL, archiveURL))) + } } label: { - Label("Archive", systemImage: "doc.zipper") + Label(R.string.localizable.detailViewToolbarItemButtonArchives(), systemSymbol: .docZipper) } - .disabled(galleryDetail?.archiveURL == nil || !AuthorizationUtil.didLogin) + .disabled(viewStore.galleryDetail?.archiveURL == nil || !CookiesUtil.didLogin) Button { - isTorrentsLinkActive.toggle() + viewStore.send(.setNavigation(.torrents)) } label: { - Label( - "Torrents".localized + ( - galleryDetail?.torrentCount ?? 0 > 0 - ? " (\(galleryDetail?.torrentCount ?? 0))" : "" - ), - systemImage: "leaf" - ) + let base = R.string.localizable.detailViewToolbarItemButtonTorrents() + let torrentCount = viewStore.galleryDetail?.torrentCount ?? 0 + let baseWithCount = [base, "(\(torrentCount))"].joined(separator: " ") + Label(torrentCount > 0 ? baseWithCount : base, systemSymbol: .leaf) } - .disabled((galleryDetail?.torrentCount ?? 0 > 0) != true) + .disabled((viewStore.galleryDetail?.torrentCount ?? 0 > 0) != true) Button { - guard let data = URL(string: gallery.galleryURL) else { return } - AppUtil.presentActivity(items: [data]) + if let galleryURL = viewStore.gallery.galleryURL { + viewStore.send(.setNavigation(.share(galleryURL))) + } } label: { - Label("Share", systemImage: "square.and.arrow.up") + Label(R.string.localizable.detailViewToolbarItemButtonShare(), systemSymbol: .squareAndArrowUp) } - } label: { - Image(systemName: "ellipsis.circle") - } - .disabled(galleryDetail == nil || detailInfo.detailLoading[gid] == true) - } - } -} - -// MARK: Private Methods -private extension DetailView { - // MARK: Life Cycle - func onStartTasks() { - if environment.navigationBarHidden { - store.dispatch(.setNavigationBarHidden(false)) - } - if environment.homeListType != .history { - PersistenceController.updateLastOpenDate(gid: gid) - } - - store.dispatch(.fulfillGalleryPreviews(gid: gid)) - store.dispatch(.fulfillGalleryContents(gid: gid)) - - fetchGalleryDetail() - updateViewControllersCount() - detectNavigations() - } - func onEndTasks() { - updateViewControllersCount() - NotificationUtil.post(.readingViewShouldHideStatusBar) - } - - // MARK: Navigation - func detectNavigations() { - if let pageIndex = detailInfo.pendingJumpPageIndices[gid] { - store.dispatch(.setPendingJumpInfos(gid: gid, pageIndex: nil, commentID: nil)) - store.dispatch(.setReadingProgress(gid: gid, tag: pageIndex)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - isReadingLinkActive.toggle() } + .disabled(viewStore.galleryDetail == nil || viewStore.loadingState == .loading) } - if let commentID = detailInfo.pendingJumpCommentIDs[gid] { - store.dispatch(.setPendingJumpInfos(gid: gid, pageIndex: nil, commentID: nil)) - commentViewScrollID = commentID - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - isCommentsLinkActive.toggle() - } - } - } - func navigateToAssociatedView(_ keyword: String) { - self.keyword = keyword - isAssociatedLinkActive.toggle() - } - func tryNavigateToUploader() { - guard let uploader = galleryDetail?.uploader else { return } - navigateToAssociatedView("uploader:" + "\"\(uploader)\"") - } - func tryNavigateToSimilarGallery() { - guard var title = galleryDetail?.title else { return } - - if let range = title.range(of: "|") { - title = String(title[.. String { - guard setting.translatesTags else { return text } - return settings.tagTranslator.translate(text: text) - } - func postComment() { - store.dispatch(.commentGallery(gid: gid, content: commentContent)) - presentSheet(state: nil) - commentContent = "" - } - func presentSheet(state: DetailViewSheetState?) { - store.dispatch(.setDetailViewSheetState(state)) - } - func updateViewControllersCount() { - store.dispatch(.setViewControllersCount) - } - func fetchGalleryDetail() { - store.dispatch(.fetchGalleryDetail(gid: gid)) } } -// MARK: HeaderView -private struct HeaderView: View { +// MARK: HeaderSection +private struct HeaderSection: View { private let gallery: Gallery - private let detail: GalleryDetail - private let favoriteNames: [Int: String]? - private let addFavAction: (Int) -> Void - private let deleteFavAction: () -> Void - private let onUploaderTapAction: () -> Void + private let galleryDetail: GalleryDetail + private let user: User + 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, - detail: GalleryDetail, - favoriteNames: [Int: String]?, - addFavAction: @escaping (Int) -> Void, - deleteFavAction: @escaping () -> Void, - onUploaderTapAction: @escaping () -> Void + galleryDetail: GalleryDetail, + user: User, showFullTitle: Bool, + showFullTitleAction: @escaping () -> Void, + favorAction: @escaping (Int) -> Void, + unfavorAction: @escaping () -> Void, + navigateReadingAction: @escaping () -> Void, + navigateUploaderAction: @escaping () -> Void ) { self.gallery = gallery - self.detail = detail - self.favoriteNames = favoriteNames - self.addFavAction = addFavAction - self.deleteFavAction = deleteFavAction - self.onUploaderTapAction = onUploaderTapAction + self.galleryDetail = galleryDetail + self.user = user + self.showFullTitle = showFullTitle + self.showFullTitleAction = showFullTitleAction + self.favorAction = favorAction + self.unfavorAction = unfavorAction + self.navigateReadingAction = navigateReadingAction + self.navigateUploaderAction = navigateUploaderAction } var body: some View { HStack { - KFImage(URL(string: gallery.coverURL)) + KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) } - .defaultModifier().scaledToFit().frame(width: width, height: height) + .defaultModifier().scaledToFit() + .frame( + width: Defaults.ImageSize.headerW, + height: Defaults.ImageSize.headerH + ) VStack(alignment: .leading) { - Text(title).fontWeight(.bold).lineLimit(3).font(.title3) - Button(gallery.uploader ?? "", action: onUploaderTapAction) + Button(action: showFullTitleAction) { + Text(galleryDetail.jpnTitle ?? galleryDetail.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.rawValue.localized, color: gallery.color, + 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: deleteFavAction) { - Image(systemName: "heart.fill").foregroundStyle(.tint).imageScale(.large) + Button(action: unfavorAction) { + Image(systemSymbol: .heartFill) } - .opacity(detail.isFavored ? 1 : 0) + .opacity(galleryDetail.isFavorited ? 1 : 0) Menu { ForEach(0..<10) { index in - Button(User.getFavNameFrom(index: index, names: favoriteNames)) { - addFavAction(index) + Button(user.getFavoriteCategory(index: index)) { + favorAction(index) } } } label: { - Image(systemName: "heart").imageScale(.large).foregroundStyle(.tint) + Image(systemSymbol: .heart) } - .opacity(detail.isFavored ? 0 : 1) + .opacity(galleryDetail.isFavorited ? 0 : 1) + } + .imageScale(.large).foregroundStyle(.tint) + .disabled(!CookiesUtil.didLogin) + Button(action: navigateReadingAction) { + Text(R.string.localizable.detailViewButtonRead()) + .bold().textCase(.uppercase).font(.headline) + .foregroundColor(.white).padding(.vertical, -2) + .padding(.horizontal, 2).lineLimit(1) } - .disabled(!AuthorizationUtil.didLogin) - Button(action: {}, label: { - NavigationLink(destination: { ReadingView(gid: gallery.gid) }, label: { - Text("Read".localized).bold().textCase(.uppercase) - .font(.headline).foregroundColor(.white) - .padding(.vertical, -2).padding(.horizontal, 2) - .lineLimit(1) - }) - }) .buttonStyle(.borderedProminent).buttonBorderShape(.capsule) } .minimumScaleFactor(0.5) } .padding(.horizontal, 10) - .frame(height: height) - } - } -} - -private extension HeaderView { - var width: CGFloat { - Defaults.ImageSize.headerW - } - var height: CGFloat { - Defaults.ImageSize.headerH - } - var title: String { - if let jpnTitle = detail.jpnTitle { - return jpnTitle - } else { - return detail.title + .frame(minHeight: Defaults.ImageSize.headerH) } } } -// MARK: DescScrollView -private struct DescScrollView: View { - struct DescScrollInfo: Identifiable, Equatable { - var id: Int { title.hashValue } - - let title: String - var titleKey: LocalizedStringKey = "" - let numeral: String - let value: String - var rating: Float = 0 - var isRating = false - } - - @State private var itemWidth = max(DeviceUtil.absWindowW / 5, 80) - +// MARK: DescriptionSection +private struct DescriptionSection: View { private let gallery: Gallery - private let detail: GalleryDetail - private var infos: [DescScrollInfo] { - [ - DescScrollInfo( - title: "DESC_SCROLL_ITEM_FAVORITED", numeral: "Times", - value: String(detail.favoredCount) - ), - DescScrollInfo( - title: "Language", - numeral: detail.language.name, - value: detail.languageAbbr - ), - DescScrollInfo( - title: "", - titleKey: LocalizedStringKey( - "\(detail.ratingCount) Ratings" - ), - numeral: "", value: "", - rating: detail.rating, isRating: true - ), - DescScrollInfo( - title: "Page Count", numeral: "Pages", - value: String(detail.pageCount) - ), - DescScrollInfo( - title: "File Size", numeral: detail.sizeType, - value: String(detail.sizeCount) - ) - ] - } + private let galleryDetail: GalleryDetail + private let navigateGalleryInfosAction: () -> Void - init(gallery: Gallery, detail: GalleryDetail) { + init( + gallery: Gallery, galleryDetail: GalleryDetail, + navigateGalleryInfosAction: @escaping () -> Void + ) { self.gallery = gallery - self.detail = detail + self.galleryDetail = galleryDetail + self.navigateGalleryInfosAction = navigateGalleryInfosAction + } + + private var infos: [DescScrollInfo] {[ + DescScrollInfo( + title: R.string.localizable.detailViewScrollSectionTitleFavorited(), + description: R.string.localizable.detailViewScrollSectionDescriptionFavorited(), + value: .init(galleryDetail.favoritedCount) + ), + DescScrollInfo( + title: R.string.localizable.detailViewScrollSectionTitleLanguage(), + description: galleryDetail.language.value, + value: galleryDetail.language.abbreviation + ), + DescScrollInfo( + title: R.string.localizable.detailViewScrollSectionTitleRatings("\(galleryDetail.ratingCount)"), + description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true + ), + DescScrollInfo( + title: R.string.localizable.detailViewScrollSectionTitlePageCount(), + description: R.string.localizable.detailViewScrollSectionDescriptionPageCount(), + value: .init(galleryDetail.pageCount) + ), + DescScrollInfo( + title: R.string.localizable.detailViewScrollSectionTitleFileSize(), + description: galleryDetail.sizeType, value: .init(galleryDetail.sizeCount) + ) + ]} + private var itemWidth: Double { + max(DeviceUtil.absWindowW / 5, 80) } var body: some View { @@ -402,27 +409,16 @@ private struct DescScrollView: View { ForEach(infos) { info in Group { if info.isRating { - DescScrollRatingItem( - titleKey: info.titleKey, - rating: info.rating - ) + DescScrollRatingItem(title: info.title, rating: info.rating) } else { - DescScrollItem( - title: info.title, - value: info.value, - numeral: info.numeral - ) + DescScrollItem(title: info.title, value: info.value, description: info.description) } } .frame(width: itemWidth).drawingGroup() Divider() if info == infos.last { - NavigationLink( - destination: GalleryInfosView( - gallery: gallery, detail: detail - ) - ) { - Image(systemName: "ellipsis") + Button(action: navigateGalleryInfosAction) { + Image(systemSymbol: .ellipsis) .font(.system(size: 20, weight: .bold)) } .frame(width: itemWidth) @@ -432,99 +428,99 @@ private struct DescScrollView: View { } } .frame(height: 60) - .onReceive(AppNotification.appWidthDidChange.publisher, perform: tryResetItemWidth) - } - - private func tryResetItemWidth(_: Any? = nil) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - guard itemWidth != max(DeviceUtil.absWindowW / 5, 80) else { return } - withAnimation { itemWidth = max(DeviceUtil.absWindowW / 5, 80) } - } } } -private struct DescScrollItem: View { - private let title: String - private let value: String - private let numeral: String +private extension DescriptionSection { + struct DescScrollInfo: Identifiable, Equatable { + var id: String { title } - init(title: String, value: String, numeral: String) { - self.title = title - self.value = value - self.numeral = numeral + 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 - var body: some View { - VStack(spacing: 3) { - Text(title.localized).textCase(.uppercase).font(.caption) - Text(value).fontWeight(.medium).font(.title3).lineLimit(1) - Text(numeral.localized).font(.caption) + init(title: String, value: String, description: String) { + self.title = title + self.value = value + self.description = description } - } -} - -private struct DescScrollRatingItem: View { - private let titleKey: LocalizedStringKey - private let rating: Float - init(titleKey: LocalizedStringKey, rating: Float) { - self.titleKey = titleKey - self.rating = rating + 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 - var body: some View { - VStack(spacing: 3) { - Text(titleKey).textCase(.uppercase).font(.caption).lineLimit(1) - Text(String(format: "%.2f", rating)).fontWeight(.medium).font(.title3) - RatingView(rating: rating).font(.system(size: 12)).foregroundStyle(.primary) + 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: ActionRow -private struct ActionRow: View { - @State private var showUserRating = false - @State private var userRating: Int - - private let detail: GalleryDetail - private let ratingAction: (Int) -> Void - private let galleryAction: () -> Void +// 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( - detail: GalleryDetail, - ratingAction: @escaping (Int) -> Void, - galleryAction: @escaping () -> Void + galleryDetail: GalleryDetail, + userRating: Int, showUserRating: Bool, + showUserRatingAction: @escaping () -> Void, + updateRatingAction: @escaping (DragGesture.Value) -> Void, + confirmRatingAction: @escaping (DragGesture.Value) -> Void, + navigateSimilarGalleryAction: @escaping () -> Void ) { - self.detail = detail - self.ratingAction = ratingAction - self.galleryAction = galleryAction - let userRating = Int(detail.userRating.halfRounded * 2) - _userRating = State(initialValue: userRating) - } - - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 0) - .onChanged(updateRating) - .onEnded(confirmRating) + 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 { - withAnimation { showUserRating.toggle() } - } label: { + Button(action: showUserRatingAction) { Spacer() - Image(systemName: "square.and.pencil") - Text("Give a Rating").bold() + Image(systemSymbol: .squareAndPencil) + Text(R.string.localizable.detailViewActionSectionButtonGiveARating()).bold() Spacer() } - .disabled(!AuthorizationUtil.didLogin) - Button(action: galleryAction) { + .disabled(!CookiesUtil.didLogin) + Button(action: navigateSimilarGalleryAction) { Spacer() - Image(systemName: "photo.on.rectangle.angled") - Text("Similar Gallery").bold() + Image(systemSymbol: .photoOnRectangleAngled) + Text(R.string.localizable.detailViewActionSectionButtonSimilarGallery()).bold() Spacer() } } @@ -535,7 +531,11 @@ private struct ActionRow: View { RatingView(rating: Float(userRating) / 2) .font(.system(size: 24)) .foregroundStyle(.yellow) - .gesture(dragGesture) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged(updateRatingAction) + .onEnded(confirmRatingAction) + ) } .padding(.top, 10) } @@ -544,34 +544,19 @@ private struct ActionRow: View { } } -private extension ActionRow { - func updateRating(value: DragGesture.Value) { - let rating = Int(value.location.x / 31 * 2) + 1 - userRating = min(max(rating, 1), 10) - } - func confirmRating(value: DragGesture.Value) { - updateRating(value: value) - ratingAction(userRating) - HapticUtil.generateFeedback(style: .soft) - withAnimation(Animation.default.delay(1)) { - showUserRating.toggle() - } - } -} - -// MARK: TagsView -private struct TagsView: View { +// MARK: TagsSection +private struct TagsSection: View { private let tags: [GalleryTag] - private let onTapAction: (String) -> Void + private let navigateAction: (String) -> Void private let translateAction: (String) -> String init( tags: [GalleryTag], - onTapAction: @escaping (String) -> Void, + navigateAction: @escaping (String) -> Void, translateAction: @escaping (String) -> String ) { self.tags = tags - self.onTapAction = onTapAction + self.navigateAction = navigateAction self.translateAction = translateAction } @@ -579,7 +564,7 @@ private struct TagsView: View { VStack(alignment: .leading) { ForEach(tags) { tag in TagRow( - tag: tag, onTapAction: onTapAction, + tag: tag, navigateAction: navigateAction, translateAction: translateAction ) } @@ -588,59 +573,57 @@ private struct TagsView: View { } } -private struct TagRow: View { - @Environment(\.colorScheme) private var colorScheme +private extension TagsSection { + struct TagRow: View { + @Environment(\.colorScheme) private var colorScheme - private let tag: GalleryTag - private let onTapAction: (String) -> Void - private let translateAction: (String) -> String - private var reversePrimary: Color { - colorScheme == .light ? .white : .black - } + private let tag: GalleryTag + private let navigateAction: (String) -> Void + private let translateAction: (String) -> String + private var reversedPrimary: Color { + colorScheme == .light ? .white : .black + } - init( - tag: GalleryTag, - onTapAction: @escaping (String) -> Void, - translateAction: @escaping (String) -> String - ) { - self.tag = tag - self.onTapAction = onTapAction - self.translateAction = translateAction - } + init( + tag: GalleryTag, + navigateAction: @escaping (String) -> Void, + translateAction: @escaping (String) -> String + ) { + self.tag = tag + self.navigateAction = navigateAction + self.translateAction = translateAction + } - var body: some View { - HStack(alignment: .top) { - Text(tag.namespace.firstLetterCapitalized.localized).fontWeight(.bold).font(.subheadline) - .foregroundColor(reversePrimary).padding(.vertical, 5).padding(.horizontal, 14) - .background(Rectangle().foregroundColor(Color(.systemGray))).cornerRadius(5) - TagCloudView( - tag: tag, font: .subheadline, textColor: .primary, backgroundColor: Color(.systemGray5), - paddingV: 5, paddingH: 14, onTapAction: onTapAction, translateAction: translateAction - ) + var body: some View { + HStack(alignment: .top) { + Text(tag.category?.value ?? tag.namespace).font(.subheadline.bold()) + .foregroundColor(reversedPrimary).padding(.vertical, 5).padding(.horizontal, 14) + .background(Rectangle().foregroundColor(Color(.systemGray))).cornerRadius(5) + TagCloudView( + tag: tag, font: .subheadline, textColor: .primary, backgroundColor: Color(.systemGray5), + paddingV: 5, paddingH: 14, onTapAction: navigateAction, translateAction: translateAction + ) + } } } } -// MARK: PreviewView -private struct PreviewView: View { - private let gid: String - private let previews: [Int: String] +// MARK: PreviewSection +private struct PreviewsSection: View { private let pageCount: Int - private let tapAction: (Int, Bool) -> Void - private let fetchAction: (Int) -> Void + private let previewURLs: [Int: URL] + private let navigatePreviewsAction: () -> Void + private let navigateReadingAction: (Int) -> Void init( - gid: String, - previews: [Int: String], - pageCount: Int, - tapAction: @escaping (Int, Bool) -> Void, - fetchAction: @escaping (Int) -> Void + pageCount: Int, previewURLs: [Int: URL], + navigatePreviewsAction: @escaping () -> Void, + navigateReadingAction: @escaping (Int) -> Void ) { - self.gid = gid - self.previews = previews self.pageCount = pageCount - self.tapAction = tapAction - self.fetchAction = fetchAction + self.previewURLs = previewURLs + self.navigatePreviewsAction = navigatePreviewsAction + self.navigateReadingAction = navigateReadingAction } private var width: CGFloat { @@ -651,42 +634,22 @@ private struct PreviewView: View { } var body: some View { - VStack { - HStack { - Text("Preview") - .fontWeight(.bold) - .font(.title3) - Spacer() - NavigationLink( - destination: MorePreviewView( - gid: gid, previews: previews, pageCount: pageCount, - tapAction: tapAction, fetchAction: fetchAction - ) - ) { - Text("Show All").font(.subheadline) - } - .opacity(pageCount > 20 ? 1 : 0) - } - .padding(.horizontal) + SubSection( + title: R.string.localizable.detailViewSectionTitlePreviews(), + showAll: pageCount > 20, showAllAction: navigatePreviewsAction + ) { ScrollView(.horizontal, showsIndicators: false) { LazyHStack { - ForEach(1.. Void - private let fetchAction: (Int) -> Void - - init( - gid: String, - previews: [Int: String], - pageCount: Int, - tapAction: @escaping (Int, Bool) -> Void, - fetchAction: @escaping (Int) -> Void - ) { - self.gid = gid - self.previews = previews - self.pageCount = pageCount - self.tapAction = tapAction - self.fetchAction = fetchAction - } - - private var gridItems: [GridItem] { - [GridItem( - .adaptive( - minimum: Defaults.ImageSize.previewMinW, - maximum: Defaults.ImageSize.previewMaxW - ), - spacing: 10 - )] - } - - var body: some View { - ScrollView { - LazyVGrid(columns: gridItems) { - ForEach(1.. Void + private let backgroundColor: Color + private let navigateCommentAction: () -> Void + private let navigatePostCommentAction: () -> Void init( - gid: String, - comments: [GalleryComment], - toggleCommentAction: @escaping () -> Void + comments: [GalleryComment], backgroundColor: Color, + navigateCommentAction: @escaping () -> Void, + navigatePostCommentAction: @escaping () -> Void ) { - self.gid = gid self.comments = comments - self.toggleCommentAction = toggleCommentAction + self.backgroundColor = backgroundColor + self.navigateCommentAction = navigateCommentAction + self.navigatePostCommentAction = navigatePostCommentAction } var body: some View { - VStack { - HStack { - Text("Comment").fontWeight(.bold).font(.title3) - Spacer() - NavigationLink(destination: CommentView(gid: gid, comments: comments)) { - Text("Show All").font(.subheadline) - } - .opacity(comments.isEmpty ? 0 : 1) - } - .padding(.horizontal) + SubSection( + title: R.string.localizable.detailViewSectionTitleComments(), + showAll: !comments.isEmpty, showAllAction: navigateCommentAction + ) { ScrollView(.horizontal, showsIndicators: false) { HStack { - ForEach(comments.prefix(6)) { comment in - CommentScrollCell(comment: comment) + ForEach(comments.prefix(min(comments.count, 6))) { comment in + CommentCell(comment: comment, backgroundColor: backgroundColor) } .withHorizontalSpacing() } .drawingGroup() } - CommentButton(action: toggleCommentAction).padding(.horizontal) - .disabled(!AuthorizationUtil.didLogin) + CommentButton(backgroundColor: backgroundColor, action: navigatePostCommentAction) + .padding(.horizontal).disabled(!CookiesUtil.didLogin) } } } -private struct CommentScrollCell: View { +private struct CommentCell: View { private let comment: GalleryComment - private var content: String { - comment.contents - .filter { [.plainText, .linkedText].contains($0.type) } - .compactMap { $0.text }.joined() - } + private let backgroundColor: Color - init(comment: GalleryComment) { + 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).fontWeight(.bold).font(.subheadline) + Text(comment.author).font(.subheadline.bold()) Spacer() Group { ZStack { - Image(systemName: "hand.thumbsup.fill") + Image(systemSymbol: .handThumbsupFill) .opacity(comment.votedUp ? 1 : 0) - Image(systemName: "hand.thumbsdown.fill") + Image(systemSymbol: .handThumbsdownFill) .opacity(comment.votedDown ? 1 : 0) } Text(comment.score ?? "") @@ -852,16 +732,60 @@ private struct CommentScrollCell: View { Text(content).padding(.top, 1) Spacer() } - .padding().background(Color(.systemGray6)) + .padding().background(backgroundColor) .frame(width: 300, height: 120) .cornerRadius(15) } } -// MARK: Definition -enum DetailViewSheetState: Identifiable { - var id: Int { hashValue } +private struct CommentButton: View { + private let backgroundColor: Color + private let action: () -> Void - case archive - case comment + init(backgroundColor: Color, action: @escaping () -> Void) { + self.backgroundColor = backgroundColor + self.action = action + } + + var body: some View { + Button(action: action) { + HStack { + Spacer() + Image(systemSymbol: .squareAndPencil) + Text(R.string.localizable.detailViewButtonPostComment()).bold() + Spacer() + } + .padding().background(backgroundColor).cornerRadius(15) + } + } +} + +struct DetailView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + DetailView( + store: .init( + initialState: .init(), + reducer: detailReducer, + environment: DetailEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + gid: .init(), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } + } } diff --git a/EhPanda/View/Detail/GalleryInfosView.swift b/EhPanda/View/Detail/GalleryInfosView.swift index a83acc63..b942080a 100644 --- a/EhPanda/View/Detail/GalleryInfosView.swift +++ b/EhPanda/View/Detail/GalleryInfosView.swift @@ -6,88 +6,122 @@ // import SwiftUI -import TTProgressHUD +import ComposableArchitecture struct GalleryInfosView: View { - @State private var hudVisible = false - @State private var hudConfig = TTProgressHUDConfig() - + private let store: Store + @ObservedObject private var viewStore: ViewStore private let gallery: Gallery - private let detail: GalleryDetail + private let galleryDetail: GalleryDetail + + init(store: Store, gallery: Gallery, galleryDetail: GalleryDetail) { + self.store = store + viewStore = ViewStore(store) + self.gallery = gallery + self.galleryDetail = galleryDetail + } private var infos: [Info] { [ - Info(title: "ID", value: detail.gid), - Info(title: "Token", value: gallery.token), - Info(title: "Title", value: detail.title), - Info(title: "Japanese title", value: detail.jpnTitle), - Info(title: "Gallery URL", value: gallery.galleryURL), - Info(title: "Cover URL", value: detail.coverURL), - Info(title: "Archive URL", value: detail.archiveURL), - Info(title: "Torrent URL", value: Defaults.URL - .galleryTorrents(gid: gallery.gid, token: gallery.token)), - Info(title: "Parent URL", value: detail.parentURL), - Info(title: "Category", value: detail.category.rawValue.localized), - Info(title: "Uploader", value: detail.uploader), - Info(title: "Posted date", value: detail.formattedDateString), - Info(title: "Visible", value: detail.visibility.value.localized), - Info(title: "Language", value: detail.language.name.localized), - Info(title: "Page count", value: String(detail.pageCount)), - Info(title: "File size", value: String(Int(detail.sizeCount)) + detail.sizeType), - Info(title: "Favorited times", value: String(detail.favoredCount)), - Info(title: "Favorited", value: (detail.isFavored ? "Yes" : "No").localized), - Info(title: "Rating count", value: String(detail.ratingCount)), - Info(title: "Average rating", value: String(Int(detail.rating))), - Info(title: "User rating", value: - detail.userRating == 0 ? nil : String(Int(detail.userRating))), - Info(title: "Torrent count", value: String(detail.torrentCount)) + Info(title: R.string.localizable.galleryInfosViewTitleID(), value: galleryDetail.gid), + Info(title: R.string.localizable.galleryInfosViewTitleToken(), value: gallery.token), + Info(title: R.string.localizable.galleryInfosViewTitleTitle(), value: galleryDetail.title), + Info(title: R.string.localizable.galleryInfosViewTitleJapaneseTitle(), value: galleryDetail.jpnTitle), + Info( + title: R.string.localizable.galleryInfosViewTitleGalleryURL(), + value: gallery.galleryURL?.absoluteString + ), + Info( + title: R.string.localizable.galleryInfosViewTitleCoverURL(), + value: galleryDetail.coverURL?.absoluteString + ), + Info( + title: R.string.localizable.galleryInfosViewTitleArchiveURL(), + value: galleryDetail.archiveURL?.absoluteString + ), + Info( + title: R.string.localizable.galleryInfosViewTitleTorrentURL(), + value: URLUtil.galleryTorrents(gid: gallery.gid, token: gallery.token).absoluteString + ), + Info( + title: R.string.localizable.galleryInfosViewTitleParentURL(), + value: galleryDetail.parentURL?.absoluteString + ), + Info( + title: R.string.localizable.galleryInfosViewTitleCategory(), + value: galleryDetail.category.value + ), + Info(title: R.string.localizable.galleryInfosViewTitleUploader(), value: galleryDetail.uploader), + Info( + title: R.string.localizable.galleryInfosViewTitlePostedDate(), + value: galleryDetail.formattedDateString + ), + Info( + title: R.string.localizable.galleryInfosViewTitleVisibility(), + value: galleryDetail.visibility.value + ), + Info(title: R.string.localizable.galleryInfosViewTitleLanguage(), value: galleryDetail.language.value), + Info(title: R.string.localizable.galleryInfosViewTitlePageCount(), value: String(galleryDetail.pageCount)), + Info( + title: R.string.localizable.galleryInfosViewTitleFileSize(), + value: String(Int(galleryDetail.sizeCount)) + galleryDetail.sizeType + ), + Info( + title: R.string.localizable.galleryInfosViewTitleFavoritedTimes(), + value: String(galleryDetail.favoritedCount) + ), + Info( + title: R.string.localizable.galleryInfosViewTitleFavorited(), + value: galleryDetail.isFavorited ? R.string.localizable.galleryInfosViewValueYes() + : R.string.localizable.galleryInfosViewValueNo() + ), + Info( + title: R.string.localizable.galleryInfosViewTitleRatingCount(), + value: String(galleryDetail.ratingCount) + ), + Info( + title: R.string.localizable.galleryInfosViewTitleAverageRating(), + value: String(Int(galleryDetail.rating)) + ), + Info( + title: R.string.localizable.galleryInfosViewTitleMyRating(), + value: galleryDetail.userRating == 0 ? nil : String(Int(galleryDetail.userRating)) + ), + Info( + title: R.string.localizable.galleryInfosViewTitleTorrentCount(), + value: String(galleryDetail.torrentCount) + ) ] } - init(gallery: Gallery, detail: GalleryDetail) { - self.gallery = gallery - self.detail = detail - } - var body: some View { - ZStack { - GeometryReader { proxy in - List(infos) { info in + GeometryReader { proxy in + List(infos) { info in + HStack { HStack { - HStack { - Text(info.title.localized) - Spacer() - } - .frame(width: proxy.size.width / 3) + Text(info.title) Spacer() - Button { - tryCopy(value: info.value) - } label: { - Text(info.value ?? "null".localized) - .lineLimit(3).font(.caption) - .foregroundStyle(.tint) + } + .frame(width: proxy.size.width / 3) + Spacer() + Button { + if let text = info.value { + viewStore.send(.copyText(text)) } + } label: { + Text(info.value ?? R.string.localizable.galleryInfosViewValueNone()) + .lineLimit(3).font(.caption) + .foregroundStyle(.tint) } } } - TTProgressHUD($hudVisible, config: hudConfig) } - .navigationTitle("Gallery infos") - } - - private func tryCopy(value: String?) { - guard let value = value else { return } - - PasteboardUtil.save(value: value) - presentHUD() - } - private func presentHUD() { - hudConfig = TTProgressHUDConfig( - type: .success, title: "Success".localized, - caption: "Copied to clipboard".localized, - shouldAutoHide: true, autoHideInterval: 1 + .progressHUD( + config: viewStore.hudConfig, + unwrapping: viewStore.binding(\.$route), + case: /GalleryInfosState.Route.hud ) - hudVisible.toggle() + .navigationTitle(R.string.localizable.galleryInfosViewTitleGalleryInfos()) } } @@ -100,8 +134,18 @@ private struct Info: Identifiable { struct GalleryInfosView_Previews: PreviewProvider { static var previews: some View { NavigationView { - GalleryInfosView(gallery: .preview, detail: .preview) - .preferredColorScheme(.dark) + GalleryInfosView( + store: .init( + initialState: .init(), + reducer: galleryInfosReducer, + environment: GalleryInfosEnvironment( + hapticClient: .live, + clipboardClient: .live + ) + ), + gallery: .preview, + galleryDetail: .preview + ) } } } diff --git a/EhPanda/View/Detail/PreviewsView.swift b/EhPanda/View/Detail/PreviewsView.swift new file mode 100644 index 00000000..2942258c --- /dev/null +++ b/EhPanda/View/Detail/PreviewsView.swift @@ -0,0 +1,108 @@ +// +// PreviewsView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/10. +// + +import SwiftUI +import Kingfisher +import ComposableArchitecture + +struct PreviewsView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + @Binding private var setting: Setting + private let blurRadius: Double + + init( + store: Store, + setting: Binding, blurRadius: Double + ) { + self.store = store + viewStore = ViewStore(store) + _setting = setting + self.blurRadius = blurRadius + } + + private var gridItems: [GridItem] { + [GridItem( + .adaptive( + minimum: Defaults.ImageSize.previewMinW, + maximum: Defaults.ImageSize.previewMaxW + ), + spacing: 10 + )] + } + + var body: some View { + ScrollView { + LazyVGrid(columns: gridItems) { + ForEach(1.. Void + private let cancelAction: () -> Void + private let onAppearAction: () -> Void + + @FocusState private var isTextEditorFocused: Bool + + init( + title: String, + content: Binding, + isFocused: Binding, + postAction: @escaping () -> Void, + cancelAction: @escaping () -> Void, + onAppearAction: @escaping () -> Void + ) { + self.title = title + _content = content + _isFocused = isFocused + self.postAction = postAction + self.cancelAction = cancelAction + self.onAppearAction = onAppearAction + } + + var body: some View { + NavigationView { + VStack { + TextEditor(text: $content).focused($isTextEditorFocused).padding() + Spacer() + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(R.string.localizable.postCommentViewButtonCancel(), action: cancelAction) + } + ToolbarItem(placement: .confirmationAction) { + Button( + R.string.localizable.postCommentViewButtonPost(), + action: postAction + ) + .disabled(content.isEmpty) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(title) + } + .synchronize($isFocused, $isTextEditorFocused) + .onAppear(perform: onAppearAction) + } +} diff --git a/EhPanda/View/Detail/RatingView.swift b/EhPanda/View/Detail/Support/RatingView.swift similarity index 94% rename from EhPanda/View/Detail/RatingView.swift rename to EhPanda/View/Detail/Support/RatingView.swift index 974c113e..6dd85536 100644 --- a/EhPanda/View/Detail/RatingView.swift +++ b/EhPanda/View/Detail/Support/RatingView.swift @@ -62,17 +62,17 @@ private extension RatingView { struct FilledStar: View { var body: some View { - Image(systemName: "star.fill") + Image(systemSymbol: .starFill) } } struct HalfFilledStar: View { var body: some View { - Image(systemName: "star.lefthalf.fill") + Image(systemSymbol: .starLeadinghalfFilled) } } struct NotFilledStar: View { var body: some View { - Image(systemName: "star") + Image(systemSymbol: .star) } } } diff --git a/EhPanda/View/Detail/TorrentsView.swift b/EhPanda/View/Detail/TorrentsView.swift index 2ae12e4a..2461a07d 100644 --- a/EhPanda/View/Detail/TorrentsView.swift +++ b/EhPanda/View/Detail/TorrentsView.swift @@ -6,169 +6,130 @@ // import SwiftUI -import TTProgressHUD - -struct TorrentsView: View, StoreAccessor { - @EnvironmentObject var store: Store - @Environment(\.colorScheme) private var colorScheme - - @State private var hudVisible = false - @State private var hudConfig = TTProgressHUDConfig() - - @State private var loadingFlag = false - @State private var loadError: AppError? - @State private var torrents = [GalleryTorrent]() +import ComposableArchitecture +struct TorrentsView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore private let gid: String private let token: String + private let blurRadius: Double - init(gid: String, token: String) { + init(store: Store, gid: String, token: String, blurRadius: Double) { + self.store = store + viewStore = ViewStore(store) self.gid = gid self.token = token + self.blurRadius = blurRadius } - // MARK: TorrentsView var body: some View { - Group { - if !torrents.isEmpty { - ZStack { - List(torrents) { torrent in - TorrentRow(torrent: torrent, action: { magnetURL in - PasteboardUtil.save(value: magnetURL) - presentHUD() - }) - .swipeActions { swipeActions(torrent: torrent) } + NavigationView { + ZStack { + List(viewStore.torrents) { torrent in + TorrentRow(torrent: torrent) { magnetURL in + viewStore.send(.copyText(magnetURL)) } - TTProgressHUD($hudVisible, config: hudConfig) + .swipeActions { + Button { + viewStore.send(.fetchTorrent(torrent.hash, torrent.torrentURL)) + } label: { + Image(systemSymbol: .arrowDownDocFill) + } + } + } + LoadingView().opacity(viewStore.loadingState == .loading && viewStore.torrents.isEmpty ? 1 : 0) + let error = (/LoadingState.failed).extract(from: viewStore.loadingState) + ErrorView(error: error ?? .unknown) { + viewStore.send(.fetchGalleryTorrents(gid, token)) } - } else if loadingFlag { - LoadingView() - } else if let error = loadError { - ErrorView(error: error, retryAction: fetchGalleryTorrents) - } else { - Circle().frame(width: 1).opacity(0.1) + .opacity(error != nil && viewStore.torrents.isEmpty ? 1 : 0) } - } - .onAppear(perform: fetchGalleryTorrents) - .navigationBarTitle("Torrents") - } - // MARK: SwipeActions - private func swipeActions(torrent: GalleryTorrent) -> some View { - Button { - tryPresentTorrentActivity(hash: torrent.hash, torrentURL: torrent.torrentURL) - } label: { - Image(systemName: "arrow.down.doc.fill") + .sheet(unwrapping: viewStore.binding(\.$route), case: /TorrentsState.Route.share) { route in + ActivityView(activityItems: [route.wrappedValue]) + .autoBlur(radius: blurRadius) + } + .progressHUD( + config: viewStore.hudConfig, + unwrapping: viewStore.binding(\.$route), + case: /TorrentsState.Route.hud + ) + .animation(.default, value: viewStore.torrents) + .onAppear { + viewStore.send(.fetchGalleryTorrents(gid, token)) + } + .navigationTitle(R.string.localizable.torrentsViewTitleTorrents()) } } } private extension TorrentsView { - func tryPresentTorrentActivity(hash: String, torrentURL: String) { - guard let torrentURL = URL(string: torrentURL) else { return } - URLSession.shared.downloadTask(with: torrentURL) { tmpURL, _, _ in - guard let tmpURL = tmpURL, - var localURL = FileManager.default.urls( - for: .cachesDirectory, in: .userDomainMask).first - else { return } + struct TorrentRow: View { + private let torrent: GalleryTorrent + private let action: (String) -> Void - localURL.appendPathComponent(hash + ".torrent") - try? FileManager.default.copyItem(at: tmpURL, to: localURL) - if FileManager.default.fileExists(atPath: localURL.path) { - AppUtil.dispatchMainSync { AppUtil.presentActivity(items: [localURL]) } - } + init(torrent: GalleryTorrent, action: @escaping (String) -> Void) { + self.torrent = torrent + self.action = action } - .resume() - } - - func presentHUD() { - hudConfig = TTProgressHUDConfig( - type: .success, title: "Success".localized, - caption: "Copied to clipboard".localized, - shouldAutoHide: true, autoHideInterval: 1 - ) - hudVisible.toggle() - } - - // MARK: Networking - func fetchGalleryTorrents() { - loadError = nil - if loadingFlag { return } - loadingFlag = true - let sToken = SubscriptionToken() - GalleryTorrentsRequest(gid: gid, token: token) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - if case .failure(let error) = completion { - Logger.error(error) - loadError = error - - Logger.error( - "GalleryTorrentsRequest Failed", - context: ["gid": gid, "Token": token, "Error": error] - ) - } - loadingFlag = false - sToken.unseal() - } receiveValue: { - torrents = $0 - - Logger.info( - "GalleryTorrentsRequest succeeded", - context: ["gid": gid, "Token": token, "Torrents count": $0.count] - ) - } - .seal(in: sToken) - } -} - -// MARK: TorrentRow -private struct TorrentRow: View { - private let torrent: GalleryTorrent - private let action: (String) -> Void - - init(torrent: GalleryTorrent, action: @escaping (String) -> Void) { - self.torrent = torrent - self.action = action - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - HStack(spacing: 3) { - Image(systemName: "arrow.up.circle") - Text("\(torrent.seedCount)") - } - HStack(spacing: 3) { - Image(systemName: "arrow.down.circle") - Text("\(torrent.peerCount)") + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + HStack(spacing: 3) { + Image(systemSymbol: .arrowUpCircle) + Text("\(torrent.seedCount)") + } + HStack(spacing: 3) { + Image(systemSymbol: .arrowDownCircle) + Text("\(torrent.peerCount)") + } + HStack(spacing: 3) { + Image(systemSymbol: .checkmarkCircle) + Text("\(torrent.downloadCount)") + } + Spacer() + HStack(spacing: 3) { + Image(systemSymbol: .docCircle) + Text(torrent.fileSize) + } } - HStack(spacing: 3) { - Image(systemName: "checkmark.circle") - Text("\(torrent.downloadCount)") + .minimumScaleFactor(0.1).lineLimit(1) + Button { + action(torrent.magnetURL) + } label: { + Text(torrent.fileName).font(.headline) } - Spacer() - HStack(spacing: 3) { - Image(systemName: "doc.circle") - Text(torrent.fileSize) + HStack { + Spacer() + Text(torrent.uploader) + Text(torrent.formattedDateString) } + .lineLimit(1).font(.callout) + .foregroundStyle(.secondary) + .minimumScaleFactor(0.5) + .padding(.top, 10) } - .minimumScaleFactor(0.1).lineLimit(1) - Button { - action(torrent.magnetURL) - } label: { - Text(torrent.fileName).font(.headline) - } - HStack { - Spacer() - Text(torrent.uploader) - Text(torrent.formattedDateString) - } - .lineLimit(1).font(.callout) - .foregroundStyle(.secondary) - .minimumScaleFactor(0.5) - .padding(.top, 10) + .padding() } - .padding() + } +} + +struct TorrentsView_Previews: PreviewProvider { + static var previews: some View { + TorrentsView( + store: .init( + initialState: .init(), + reducer: torrentsReducer, + environment: TorrentsEnvironment( + fileClient: .live, + hapticClient: .live, + clipboardClient: .live + ) + ), + gid: .init(), + token: .init(), + blurRadius: 0 + ) } } diff --git a/EhPanda/View/Favorites/FavoritesStore.swift b/EhPanda/View/Favorites/FavoritesStore.swift new file mode 100644 index 00000000..7e46cb27 --- /dev/null +++ b/EhPanda/View/Favorites/FavoritesStore.swift @@ -0,0 +1,256 @@ +// +// FavoritesStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/29. +// + +import SwiftUI +import IdentifiedCollections +import ComposableArchitecture + +// MARK: State +struct FavoritesState: Equatable { + enum Route: Equatable { + case quickSearch + case detail(String) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + @BindableState var jumpPageIndex = "" + @BindableState var jumpPageAlertFocused = false + @BindableState var jumpPageAlertPresented = false + + var index = -1 + var sortOrder: FavoritesSortOrder? + + var rawGalleries = [Int: [Gallery]]() + var rawPageNumber = [Int: PageNumber]() + var rawLoadingState = [Int: LoadingState]() + var rawFooterLoadingState = [Int: LoadingState]() + + var galleries: [Gallery]? { + rawGalleries[index] + } + var pageNumber: PageNumber? { + rawPageNumber[index] + } + var loadingState: LoadingState? { + rawLoadingState[index] + } + var footerLoadingState: LoadingState? { + rawFooterLoadingState[index] + } + + @Heap var detailState: DetailState! + var quickSearchState = QuickSearchState() + + mutating func insertGalleries(index: Int, galleries: [Gallery]) { + galleries.forEach { gallery in + if rawGalleries[index]?.contains(gallery) == false { + rawGalleries[index]?.append(gallery) + } + } + } +} + +// MARK: Action +enum FavoritesAction: BindableAction { + case binding(BindingAction) + case setNavigation(FavoritesState.Route?) + case setFavoritesIndex(Int) + case clearSubStates + case onNotLoginViewButtonTapped + + case performJumpPage + case presentJumpPageAlert + case setJumpPageAlertFocused(Bool) + + case fetchGalleries(Int? = nil, String? = nil, FavoritesSortOrder? = nil) + case fetchGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) + + case detail(DetailAction) + case quickSearch(QuickSearchAction) +} + +// MARK: Environment +struct FavoritesEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +// MARK: Reducer +let favoritesReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$jumpPageAlertPresented): + if !state.jumpPageAlertPresented { + state.jumpPageAlertFocused = false + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .setFavoritesIndex(let index): + state.index = index + guard state.galleries?.isEmpty != false else { return .none } + return .init(value: FavoritesAction.fetchGalleries()) + + case .clearSubStates: + state.detailState = .init() + return .init(value: .detail(.teardown)) + + case .onNotLoginViewButtonTapped: + return .none + + case .performJumpPage: + guard let index = Int(state.jumpPageIndex), + let pageNumber = state.pageNumber, + index > 0, index <= pageNumber.maximum + 1 + else { + return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() + } + return .init(value: .fetchGalleries(index - 1)) + + case .presentJumpPageAlert: + state.jumpPageAlertPresented = true + return environment.hapticClient.generateFeedback(.light).fireAndForget() + + case .setJumpPageAlertFocused(let isFocused): + state.jumpPageAlertFocused = isFocused + return .none + + case .fetchGalleries(let pageNum, let keyword, let sortOrder): + guard state.loadingState != .loading else { return .none } + state.rawLoadingState[state.index] = .loading + if let keyword = keyword { + state.keyword = keyword + } + if state.pageNumber == nil { + state.rawPageNumber[state.index] = PageNumber() + } else { + state.rawPageNumber[state.index]?.current = 0 + } + return FavoritesGalleriesRequest( + favIndex: state.index, pageNum: pageNum, keyword: state.keyword, sortOrder: sortOrder + ) + .effect.map { [index = state.index] result in FavoritesAction.fetchGalleriesDone(index, result) } + + case .fetchGalleriesDone(let targetFavIndex, let result): + state.rawLoadingState[targetFavIndex] = .idle + switch result { + case .success(let (pageNumber, sortOrder, galleries)): + guard !galleries.isEmpty else { + guard pageNumber.current < pageNumber.maximum else { + state.rawLoadingState[targetFavIndex] = .failed(.notFound) + return .none + } + return .init(value: .fetchMoreGalleries) + } + state.rawPageNumber[targetFavIndex] = pageNumber + state.rawGalleries[targetFavIndex] = galleries + state.sortOrder = sortOrder + return environment.databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.rawLoadingState[targetFavIndex] = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber ?? .init() + guard pageNumber.current + 1 <= pageNumber.maximum, + state.footerLoadingState != .loading + else { return .none } + state.rawFooterLoadingState[state.index] = .loading + let pageNum = pageNumber.current + 1 + let lastID = state.galleries?.last?.id ?? "" + return MoreFavoritesGalleriesRequest( + favIndex: state.index, lastID: lastID, pageNum: pageNum, keyword: state.keyword + ) + .effect.map { [index = state.index] result in FavoritesAction.fetchMoreGalleriesDone(index, result) } + + case .fetchMoreGalleriesDone(let targetFavIndex, let result): + state.rawFooterLoadingState[targetFavIndex] = .idle + switch result { + case .success(let (pageNumber, sortOrder, galleries)): + state.rawPageNumber[targetFavIndex] = pageNumber + state.insertGalleries(index: targetFavIndex, galleries: galleries) + state.sortOrder = sortOrder + + var effects: [Effect] = [ + environment.databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.current < pageNumber.maximum { + effects.append(.init(value: .fetchMoreGalleries)) + } + return .merge(effects) + + case .failure(let error): + state.rawFooterLoadingState[targetFavIndex] = .failed(error) + } + return .none + + case .detail: + return .none + + case .quickSearch: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /FavoritesState.Route.quickSearch, + hapticClient: \.hapticClient + ) + .binding(), + detailReducer.pullback( + state: \FavoritesState.detailState, + action: /FavoritesAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + quickSearchReducer.pullback( + state: \.quickSearchState, + action: /FavoritesAction.quickSearch, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ) +) diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift new file mode 100644 index 00000000..ccfed59f --- /dev/null +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -0,0 +1,172 @@ +// +// FavoritesView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/13. +// + +import SwiftUI +import AlertKit +import ComposableArchitecture + +struct FavoritesView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + private var navigationTitle: String { + let favoriteCategory = user.getFavoriteCategory(index: viewStore.index) + return (viewStore.index == -1 ? R.string.localizable.favoritesViewTitleFavorites() : favoriteCategory) + } + + var body: some View { + NavigationView { + ZStack { + if CookiesUtil.didLogin { + GenericList( + galleries: viewStore.galleries ?? [], + setting: setting, + pageNumber: viewStore.pageNumber, + loadingState: viewStore.loadingState ?? .idle, + footerLoadingState: viewStore.footerLoadingState ?? .idle, + fetchAction: { viewStore.send(.fetchGalleries()) }, + fetchMoreAction: { viewStore.send(.fetchMoreGalleries) }, + navigateAction: { viewStore.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: setting.translatesTags) + } + ) + } else { + NotLoginView(action: { viewStore.send(.onNotLoginViewButtonTapped) }) + } + } + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /FavoritesState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: FavoritesAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + .navigationViewStyle(.stack) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /FavoritesState.Route.quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: FavoritesAction.quickSearch) + ) { keyword in + viewStore.send(.setNavigation(nil)) + viewStore.send(.fetchGalleries(nil, keyword)) + } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .jumpPageAlert( + index: viewStore.binding(\.$jumpPageIndex), + isPresented: viewStore.binding(\.$jumpPageAlertPresented), + isFocused: viewStore.binding(\.$jumpPageAlertFocused), + pageNumber: viewStore.pageNumber ?? .init(), + jumpAction: { viewStore.send(.performJumpPage) } + ) + .animation(.default, value: viewStore.jumpPageAlertPresented) + .searchable(text: viewStore.binding(\.$keyword)) + .onSubmit(of: .search) { + viewStore.send(.fetchGalleries()) + } + .onAppear { + if viewStore.galleries?.isEmpty != false && CookiesUtil.didLogin { + DispatchQueue.main.async { + viewStore.send(.fetchGalleries()) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(navigationTitle) + } + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /FavoritesState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: FavoritesAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + } + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(tint: .primary, disabled: viewStore.jumpPageAlertPresented) { + FavoritesIndexMenu(user: user, index: viewStore.index) { index in + if index != viewStore.index { + viewStore.send(.setFavoritesIndex(index)) + } + } + SortOrderMenu(sortOrder: viewStore.sortOrder) { order in + if viewStore.sortOrder != order { + viewStore.send(.fetchGalleries(nil, nil, order)) + } + } + ToolbarFeaturesMenu(symbolRenderingMode: .hierarchical) { + QuickSearchButton { + viewStore.send(.setNavigation(.quickSearch)) + } + JumpPageButton(pageNumber: viewStore.pageNumber ?? .init()) { + viewStore.send(.presentJumpPageAlert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + viewStore.send(.setJumpPageAlertFocused(true)) + } + } + } + } + } +} + +struct FavoritesView_Previews: PreviewProvider { + static var previews: some View { + FavoritesView( + store: .init( + initialState: .init(), + reducer: favoritesReducer, + environment: FavoritesEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } +} diff --git a/EhPanda/View/Home/AuthView.swift b/EhPanda/View/Home/AuthView.swift deleted file mode 100644 index 62bd1c65..00000000 --- a/EhPanda/View/Home/AuthView.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// AuthView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/02/09. -// - -import SwiftUI - -struct AuthView: View, StoreAccessor { - @EnvironmentObject var store: Store - @State private var isLaunchingApp = true - @Binding private var blurRadius: CGFloat - @State private var enterBackgroundDate: Date? - - init(blurRadius: Binding) { - _blurRadius = blurRadius - } - - // MARK: AuthView - var body: some View { - Image(systemName: "lock.fill") - .font(.system(size: 80)).opacity(isAppUnlocked ? 0 : 1) - .onAppear(perform: onStartTasks).onTapGesture(perform: authenticate) - .onReceive(UIApplication.willResignActiveNotification.publisher, perform: onResignActive) - .onReceive(UIApplication.didBecomeActiveNotification.publisher, perform: onDidBecomeActive) - .onReceive(UIApplication.didEnterBackgroundNotification.publisher, perform: onDidEnterBackground) - .onReceive(UIApplication.willEnterForegroundNotification.publisher, perform: onWillEnterForeground) - } -} - -private extension AuthView { - var autoLockThreshold: Int { - autoLockPolicy.rawValue - } - - // MARK: Life Cycle - func onStartTasks() { - guard autoLockPolicy != .never && isLaunchingApp else { return } - isLaunchingApp = false - lock() - } - func onResignActive(_: Any? = nil) { - guard backgroundBlurRadius > 0 else { return } - setBlurEffect(activated: true) - } - func onDidBecomeActive(_: Any? = nil) { - guard isAppUnlocked else { return } - setBlurEffect(activated: false) - } - func onDidEnterBackground(_: Any? = nil) { - guard autoLockThreshold >= 0 else { return } - enterBackgroundDate = Date() - } - func onWillEnterForeground(_: Any? = nil) { - if autoLockThreshold >= 0 { - tryLock() - if !isAppUnlocked { - authenticate() - } - } else { - setBlurEffect(activated: false) - } - } - - // MARK: Authorization - func setBlurEffect(activated: Bool) { - withAnimation(.linear(duration: 0.1)) { - blurRadius = activated ? backgroundBlurRadius : 0 - } - store.dispatch(.setBlurEffect(activated: activated)) - } - func setAppLock(activated: Bool) { - store.dispatch(.setAppLock(activated: activated)) - } - - func lock() { - setAppLock(activated: true) - setBlurEffect(activated: true) - } - func tryLock() { - if let resignDate = enterBackgroundDate, - Date().timeIntervalSince(resignDate) - > Double(autoLockThreshold) { lock() } - enterBackgroundDate = nil - } - - func authenticate() { - AuthorizationUtil.localAuth( - reason: "The App has been locked due to the auto-lock expiration.", - successAction: { - setAppLock(activated: false) - setBlurEffect(activated: false) - } - ) - } -} diff --git a/EhPanda/View/Home/DataFlow/FrontpageStore.swift b/EhPanda/View/Home/DataFlow/FrontpageStore.swift new file mode 100644 index 00000000..8bb35831 --- /dev/null +++ b/EhPanda/View/Home/DataFlow/FrontpageStore.swift @@ -0,0 +1,222 @@ +// +// FrontpageStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import ComposableArchitecture + +struct FrontpageState: Equatable { + enum Route: Equatable { + case filters + case detail(String) + } + struct CancelID: Hashable { + let id = String(describing: FrontpageState.self) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + @BindableState var jumpPageIndex = "" + @BindableState var jumpPageAlertFocused = false + @BindableState var jumpPageAlertPresented = false + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + var galleries = [Gallery]() + var pageNumber = PageNumber() + var loadingState: LoadingState = .idle + var footerLoadingState: LoadingState = .idle + + var filtersState = FiltersState() + @Heap var detailState: DetailState! + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } +} + +enum FrontpageAction: BindableAction { + case binding(BindingAction) + case setNavigation(FrontpageState.Route?) + case clearSubStates + + case performJumpPage + case presentJumpPageAlert + case setJumpPageAlertFocused(Bool) + + case teardown + case fetchGalleries(Int? = nil) + case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + + case filters(FiltersAction) + case detail(DetailAction) +} + +struct FrontpageEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let frontpageReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$jumpPageAlertPresented): + if !state.jumpPageAlertPresented { + state.jumpPageAlertFocused = false + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + return .init(value: .detail(.teardown)) + + case .performJumpPage: + guard let index = Int(state.jumpPageIndex), index > 0, index <= state.pageNumber.maximum + 1 else { + return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() + } + return .init(value: .fetchGalleries(index - 1)) + + case .presentJumpPageAlert: + state.jumpPageAlertPresented = true + return environment.hapticClient.generateFeedback(.light).fireAndForget() + + case .setJumpPageAlertFocused(let isFocused): + state.jumpPageAlertFocused = isFocused + return .none + + case .teardown: + return .cancel(id: FrontpageState.CancelID()) + + case .fetchGalleries(let pageNum): + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + state.pageNumber.current = 0 + let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) + return FrontpageGalleriesRequest(filter: filter, pageNum: pageNum) + .effect.map(FrontpageAction.fetchGalleriesDone).cancellable(id: FrontpageState.CancelID()) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + guard pageNumber.current < pageNumber.maximum else { + state.loadingState = .failed(.notFound) + return .none + } + return .init(value: .fetchMoreGalleries) + } + state.pageNumber = pageNumber + state.galleries = galleries + return environment.databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.current + 1 <= pageNumber.maximum, + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let pageNum = pageNumber.current + 1 + let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) + return MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID, pageNum: pageNum) + .effect.map(FrontpageAction.fetchMoreGalleriesDone).cancellable(id: FrontpageState.CancelID()) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.pageNumber = pageNumber + state.insertGalleries(galleries) + + var effects: [Effect] = [ + environment.databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.current < pageNumber.maximum { + effects.append(.init(value: .fetchMoreGalleries)) + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .filters: + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /FrontpageState.Route.filters, + hapticClient: \.hapticClient + ) + .binding(), + filtersReducer.pullback( + state: \.filtersState, + action: /FrontpageAction.filters, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + detailReducer.pullback( + state: \.detailState, + action: /FrontpageAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Home/DataFlow/HistoryStore.swift b/EhPanda/View/Home/DataFlow/HistoryStore.swift new file mode 100644 index 00000000..e5bd43bf --- /dev/null +++ b/EhPanda/View/Home/DataFlow/HistoryStore.swift @@ -0,0 +1,120 @@ +// +// HistoryStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct HistoryState: Equatable { + enum Route: Equatable { + case detail(String) + case clearHistory + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + @BindableState var clearDialogPresented = false + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + var galleries = [Gallery]() + var loadingState: LoadingState = .idle + + @Heap var detailState: DetailState! +} + +enum HistoryAction: BindableAction { + case binding(BindingAction) + case setNavigation(HistoryState.Route?) + case clearSubStates + case clearHistoryGalleries + + case fetchGalleries + case fetchGalleriesDone([Gallery]) + + case detail(DetailAction) +} + +struct HistoryEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let historyReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + return .init(value: .detail(.teardown)) + + case .clearHistoryGalleries: + return .merge( + environment.databaseClient.clearHistoryGalleries().fireAndForget(), + .init(value: .fetchGalleries) + .delay(for: .milliseconds(200), scheduler: DispatchQueue.main).eraseToEffect() + ) + + case .fetchGalleries: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return environment.databaseClient.fetchHistoryGalleries().map(HistoryAction.fetchGalleriesDone) + + case .fetchGalleriesDone(let galleries): + state.loadingState = .idle + if galleries.isEmpty { + state.loadingState = .failed(.notFound) + } else { + state.galleries = galleries + } + return .none + + case .detail: + return .none + } + } + .binding(), + detailReducer.pullback( + state: \.detailState, + action: /HistoryAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Home/DataFlow/HomeStore.swift b/EhPanda/View/Home/DataFlow/HomeStore.swift new file mode 100644 index 00000000..4dd2cbd5 --- /dev/null +++ b/EhPanda/View/Home/DataFlow/HomeStore.swift @@ -0,0 +1,367 @@ +// +// HomeStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/05. +// + +import SwiftUI +import Kingfisher +import UIImageColors +import ComposableArchitecture + +struct HomeState: Equatable { + enum Route: Equatable, Hashable { + case detail(String) + case misc(HomeMiscGridType) + case section(HomeSectionType) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var cardPageIndex = 1 + @BindableState var currentCardID = "" + var allowsCardHitTesting = true + var rawCardColors = [String: [Color]]() + var cardColors: [Color] { + rawCardColors[currentCardID] ?? [.clear] + } + + var popularGalleries = [Gallery]() + var popularLoadingState: LoadingState = .idle + var frontpageGalleries = [Gallery]() + var frontpageLoadingState: LoadingState = .idle + var toplistsGalleries = [Int: [Gallery]]() + var toplistsLoadingState = [Int: LoadingState]() + + var frontpageState = FrontpageState() + var toplistsState = ToplistsState() + var popularState = PopularState() + var watchedState = WatchedState() + var historyState = HistoryState() + @Heap var detailState: DetailState! + + mutating func setPopularGalleries(_ galleries: [Gallery]) { + let sortedGalleries = galleries.sorted { lhs, rhs in + lhs.title.count > rhs.title.count + } + var trimmedGalleries = Array(sortedGalleries.prefix(10)) + .removeDuplicates(by: \.trimmedTitle) + if trimmedGalleries.count >= 6 { + trimmedGalleries = Array(trimmedGalleries.prefix(6)) + } + trimmedGalleries.shuffle() + popularGalleries = trimmedGalleries + currentCardID = trimmedGalleries[cardPageIndex].gid + } + mutating func setFrontpageGalleries(_ galleries: [Gallery]) { + frontpageGalleries = Array(galleries.prefix(25)) + .removeDuplicates(by: \.trimmedTitle) + } +} + +enum HomeAction: BindableAction { + case binding(BindingAction) + case setNavigation(HomeState.Route?) + case clearSubStates + case setAllowsCardHitTesting(Bool) + case analyzeImageColors(String, RetrieveImageResult) + case analyzeImageColorsDone(String, UIImageColors?) + + case fetchAllGalleries + case fetchAllToplistsGalleries + case fetchPopularGalleries + case fetchPopularGalleriesDone(Result<[Gallery], AppError>) + case fetchFrontpageGalleries(Int? = nil) + case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchToplistsGalleries(Int, Int? = nil) + case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) + + case frontpage(FrontpageAction) + case toplists(ToplistsAction) + case popular(PopularAction) + case watched(WatchedAction) + case history(HistoryAction) + case detail(DetailAction) +} + +struct HomeEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let libraryClient: LibraryClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let homeReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$cardPageIndex): + guard state.cardPageIndex < state.popularGalleries.count else { return .none } + state.currentCardID = state.popularGalleries[state.cardPageIndex].gid + state.allowsCardHitTesting = false + return .init(value: .setAllowsCardHitTesting(true)) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToEffect() + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.frontpageState = .init() + state.toplistsState = .init() + state.popularState = .init() + state.watchedState = .init() + state.historyState = .init() + state.detailState = .init() + return .merge( + .init(value: .frontpage(.teardown)), + .init(value: .toplists(.teardown)), + .init(value: .popular(.teardown)), + .init(value: .watched(.teardown)), + .init(value: .detail(.teardown)) + ) + + case .setAllowsCardHitTesting(let isAllowed): + state.allowsCardHitTesting = isAllowed + return .none + + case .fetchAllGalleries: + return .merge( + .init(value: .fetchPopularGalleries), + .init(value: .fetchFrontpageGalleries()), + .init(value: .fetchAllToplistsGalleries) + ) + + case .fetchAllToplistsGalleries: + return .merge( + ToplistsType.allCases.map({ HomeAction.fetchToplistsGalleries($0.categoryIndex) }) + .map(Effect.init) + ) + + case .fetchPopularGalleries: + guard state.popularLoadingState != .loading else { return .none } + state.popularLoadingState = .loading + state.rawCardColors = [String: [Color]]() + let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) + return PopularGalleriesRequest(filter: filter) + .effect.map(HomeAction.fetchPopularGalleriesDone) + + 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 environment.databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.popularLoadingState = .failed(error) + } + return .none + + case .fetchFrontpageGalleries(let pageNum): + guard state.frontpageLoadingState != .loading else { return .none } + state.frontpageLoadingState = .loading + let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) + return FrontpageGalleriesRequest(filter: filter, pageNum: pageNum) + .effect.map(HomeAction.fetchFrontpageGalleriesDone) + + 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 environment.databaseClient.cacheGalleries(galleries).fireAndForget() + 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 ToplistsGalleriesRequest(catIndex: index, pageNum: pageNum) + .effect.map({ HomeAction.fetchToplistsGalleriesDone(index, $0) }) + + 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 environment.databaseClient.cacheGalleries(galleries).fireAndForget() + 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 environment.libraryClient.analyzeImageColors(result.image) + .map({ HomeAction.analyzeImageColorsDone(gid, $0) }) + + 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 + } + } + .binding(), + frontpageReducer.pullback( + state: \.frontpageState, + action: /HomeAction.frontpage, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + toplistsReducer.pullback( + state: \.toplistsState, + action: /HomeAction.toplists, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + popularReducer.pullback( + state: \.popularState, + action: /HomeAction.popular, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + watchedReducer.pullback( + state: \.watchedState, + action: /HomeAction.watched, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + historyReducer.pullback( + state: \.historyState, + action: /HomeAction.history, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + detailReducer.pullback( + state: \.detailState, + action: /HomeAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Home/DataFlow/PopularStore.swift b/EhPanda/View/Home/DataFlow/PopularStore.swift new file mode 100644 index 00000000..d770451c --- /dev/null +++ b/EhPanda/View/Home/DataFlow/PopularStore.swift @@ -0,0 +1,146 @@ +// +// PopularStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct PopularState: Equatable { + enum Route: Equatable { + case filters + case detail(String) + } + struct CancelID: Hashable { + let id = String(describing: PopularState.self) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + var galleries = [Gallery]() + var loadingState: LoadingState = .idle + + var filtersState = FiltersState() + @Heap var detailState: DetailState! +} + +enum PopularAction: BindableAction { + case binding(BindingAction) + case setNavigation(PopularState.Route?) + case clearSubStates + + case teardown + case fetchGalleries + case fetchGalleriesDone(Result<[Gallery], AppError>) + + case filters(FiltersAction) + case detail(DetailAction) +} + +struct PopularEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let popularReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + return .init(value: .detail(.teardown)) + + case .teardown: + return .cancel(id: PopularState.CancelID()) + + case .fetchGalleries: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) + return PopularGalleriesRequest(filter: filter) + .effect.map(PopularAction.fetchGalleriesDone).cancellable(id: PopularState.CancelID()) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let galleries): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.galleries = galleries + return environment.databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .filters: + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /PopularState.Route.filters, + hapticClient: \.hapticClient + ) + .binding(), + filtersReducer.pullback( + state: \.filtersState, + action: /PopularAction.filters, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + detailReducer.pullback( + state: \.detailState, + action: /PopularAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Home/DataFlow/ToplistsStore.swift b/EhPanda/View/Home/DataFlow/ToplistsStore.swift new file mode 100644 index 00000000..b24baae0 --- /dev/null +++ b/EhPanda/View/Home/DataFlow/ToplistsStore.swift @@ -0,0 +1,229 @@ +// +// ToplistsStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import ComposableArchitecture + +struct ToplistsState: Equatable { + enum Route: Equatable { + case detail(String) + } + struct CancelID: Hashable { + let id = String(describing: ToplistsState.self) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + @BindableState var jumpPageIndex = "" + @BindableState var jumpPageAlertFocused = false + @BindableState var jumpPageAlertPresented = false + + var type: ToplistsType = .yesterday + + var filteredGalleries: [Gallery]? { + guard !keyword.isEmpty else { return galleries } + return galleries?.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + + var rawGalleries = [ToplistsType: [Gallery]]() + var rawPageNumber = [ToplistsType: PageNumber]() + var rawLoadingState = [ToplistsType: LoadingState]() + var rawFooterLoadingState = [ToplistsType: LoadingState]() + + var galleries: [Gallery]? { + rawGalleries[type] + } + var pageNumber: PageNumber? { + rawPageNumber[type] + } + var loadingState: LoadingState? { + rawLoadingState[type] + } + var footerLoadingState: LoadingState? { + rawFooterLoadingState[type] + } + + @Heap var detailState: DetailState! + + mutating func insertGalleries(type: ToplistsType, galleries: [Gallery]) { + galleries.forEach { gallery in + if rawGalleries[type]?.contains(gallery) == false { + rawGalleries[type]?.append(gallery) + } + } + } +} + +enum ToplistsAction: BindableAction { + case binding(BindingAction) + case setNavigation(ToplistsState.Route?) + case setToplistsType(ToplistsType) + case clearSubStates + + case performJumpPage + case presentJumpPageAlert + case setJumpPageAlertFocused(Bool) + + case teardown + case fetchGalleries(Int? = nil) + case fetchGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) + + case detail(DetailAction) +} + +struct ToplistsEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let toplistsReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$jumpPageAlertPresented): + if !state.jumpPageAlertPresented { + state.jumpPageAlertFocused = false + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .setToplistsType(let type): + state.type = type + guard state.galleries?.isEmpty != false else { return .none } + return .init(value: ToplistsAction.fetchGalleries()) + + case .clearSubStates: + state.detailState = .init() + return .init(value: .detail(.teardown)) + + case .performJumpPage: + guard let index = Int(state.jumpPageIndex), + let pageNumber = state.pageNumber, + index > 0, index <= pageNumber.maximum + 1 else { + return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() + } + return .init(value: .fetchGalleries(index - 1)) + + case .presentJumpPageAlert: + state.jumpPageAlertPresented = true + return environment.hapticClient.generateFeedback(.light).fireAndForget() + + case .setJumpPageAlertFocused(let isFocused): + state.jumpPageAlertFocused = isFocused + return .none + + case .teardown: + return .cancel(id: ToplistsState.CancelID()) + + case .fetchGalleries(let pageNum): + guard state.loadingState != .loading else { return .none } + state.rawLoadingState[state.type] = .loading + if state.pageNumber == nil { + state.rawPageNumber[state.type] = PageNumber() + } else { + state.rawPageNumber[state.type]?.current = 0 + } + return ToplistsGalleriesRequest(catIndex: state.type.categoryIndex, pageNum: pageNum) + .effect.map({ [type = state.type] in ToplistsAction.fetchGalleriesDone(type, $0) }) + .cancellable(id: ToplistsState.CancelID()) + + case .fetchGalleriesDone(let type, let result): + state.rawLoadingState[type] = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + guard pageNumber.current < pageNumber.maximum else { + state.rawLoadingState[type] = .failed(.notFound) + return .none + } + return .init(value: .fetchMoreGalleries) + } + state.rawPageNumber[type] = pageNumber + state.rawGalleries[type] = galleries + return environment.databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.rawLoadingState[type] = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber ?? .init() + guard pageNumber.current + 1 <= pageNumber.maximum, + state.footerLoadingState != .loading + else { return .none } + state.rawFooterLoadingState[state.type] = .loading + let pageNum = pageNumber.current + 1 + let lastID = state.rawGalleries[state.type]?.last?.id ?? "" + return MoreToplistsGalleriesRequest(catIndex: state.type.categoryIndex, pageNum: pageNum) + .effect.map({ [type = state.type] in ToplistsAction.fetchMoreGalleriesDone(type, $0) }) + .cancellable(id: ToplistsState.CancelID()) + + case .fetchMoreGalleriesDone(let type, let result): + state.rawFooterLoadingState[type] = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.rawPageNumber[type] = pageNumber + state.insertGalleries(type: type, galleries: galleries) + + var effects: [Effect] = [ + environment.databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.current < pageNumber.maximum { + effects.append(.init(value: .fetchMoreGalleries)) + } + return .merge(effects) + + case .failure(let error): + state.rawFooterLoadingState[type] = .failed(error) + } + return .none + + case .detail: + return .none + } + } + .binding(), + detailReducer.pullback( + state: \.detailState, + action: /ToplistsAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Home/DataFlow/WatchedStore.swift b/EhPanda/View/Home/DataFlow/WatchedStore.swift new file mode 100644 index 00000000..083b5a1d --- /dev/null +++ b/EhPanda/View/Home/DataFlow/WatchedStore.swift @@ -0,0 +1,251 @@ +// +// WatchedStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct WatchedState: Equatable { + enum Route: Equatable { + case filters + case quickSearch + case detail(String) + } + struct CancelID: Hashable { + let id = String(describing: WatchedState.self) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + @BindableState var jumpPageIndex = "" + @BindableState var jumpPageAlertFocused = false + @BindableState var jumpPageAlertPresented = false + + var galleries = [Gallery]() + var pageNumber = PageNumber() + var loadingState: LoadingState = .idle + var footerLoadingState: LoadingState = .idle + + var filtersState = FiltersState() + var quickSearchState = QuickSearchState() + @Heap var detailState: DetailState! + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } +} + +enum WatchedAction: BindableAction { + case binding(BindingAction) + case setNavigation(WatchedState.Route?) + case clearSubStates + case onNotLoginViewButtonTapped + + case performJumpPage + case presentJumpPageAlert + case setJumpPageAlertFocused(Bool) + + case teardown + case fetchGalleries(Int? = nil, String? = nil) + case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + + case filters(FiltersAction) + case detail(DetailAction) + case quickSearch(QuickSearchAction) +} + +struct WatchedEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let watchedReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$jumpPageAlertPresented): + if !state.jumpPageAlertPresented { + state.jumpPageAlertFocused = false + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + state.quickSearchState = .init() + return .merge( + .init(value: .detail(.teardown)), + .init(value: .quickSearch(.teardown)) + ) + + case .onNotLoginViewButtonTapped: + return .none + + case .performJumpPage: + guard let index = Int(state.jumpPageIndex), index > 0, index <= state.pageNumber.maximum + 1 else { + return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() + } + return .init(value: .fetchGalleries(index - 1)) + + case .presentJumpPageAlert: + state.jumpPageAlertPresented = true + return environment.hapticClient.generateFeedback(.light).fireAndForget() + + case .setJumpPageAlertFocused(let isFocused): + state.jumpPageAlertFocused = isFocused + return .none + + case .teardown: + return .cancel(id: WatchedState.CancelID()) + + case .fetchGalleries(let pageNum, let keyword): + guard state.loadingState != .loading else { return .none } + if let keyword = keyword { + state.keyword = keyword + } + state.loadingState = .loading + state.pageNumber.current = 0 + let filter = environment.databaseClient.fetchFilterSynchronously(range: .watched) + return WatchedGalleriesRequest(filter: filter, pageNum: pageNum, keyword: state.keyword) + .effect.map(WatchedAction.fetchGalleriesDone).cancellable(id: WatchedState.CancelID()) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + guard pageNumber.current < pageNumber.maximum else { + state.loadingState = .failed(.notFound) + return .none + } + return .init(value: .fetchMoreGalleries) + } + state.pageNumber = pageNumber + state.galleries = galleries + return environment.databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.current + 1 <= pageNumber.maximum, + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let pageNum = pageNumber.current + 1 + let filter = environment.databaseClient.fetchFilterSynchronously(range: .watched) + return MoreWatchedGalleriesRequest( + filter: filter, lastID: lastID, pageNum: pageNum, keyword: state.keyword + ) + .effect.map(WatchedAction.fetchMoreGalleriesDone).cancellable(id: WatchedState.CancelID()) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.pageNumber = pageNumber + state.insertGalleries(galleries) + + var effects: [Effect] = [ + environment.databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.current < pageNumber.maximum { + effects.append(.init(value: .fetchMoreGalleries)) + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .quickSearch: + return .none + + case .filters: + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /WatchedState.Route.quickSearch, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /WatchedState.Route.filters, + hapticClient: \.hapticClient + ) + .binding(), + filtersReducer.pullback( + state: \.filtersState, + action: /WatchedAction.filters, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + quickSearchReducer.pullback( + state: \.quickSearchState, + action: /WatchedAction.quickSearch, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + detailReducer.pullback( + state: \.detailState, + action: /WatchedAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Home/FilterView.swift b/EhPanda/View/Home/FilterView.swift deleted file mode 100644 index 580ed3fa..00000000 --- a/EhPanda/View/Home/FilterView.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// FilterView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/08. -// - -import SwiftUI - -struct FilterView: View, StoreAccessor { - @EnvironmentObject var store: Store - @State private var resetDialogPresented = false - @State private var filterRange: FilterRange = .search - - private var filterBinding: Binding { - filterRange == .search - ? $store.appState.settings.searchFilter - : $store.appState.settings.globalFilter - } - - // MARK: FilterView - var body: some View { - NavigationView { - Form { - BasicSection( - filter: filterBinding, filterRange: $filterRange, - resetDialogPresented: $resetDialogPresented - ) - AdvancedSection(filter: filterBinding) - } - .confirmationDialog( - "Are you sure to reset?", isPresented: $resetDialogPresented, titleVisibility: .visible - ) { - Button("Reset", role: .destructive) { - store.dispatch(.resetFilter(range: filterRange)) - } - } - .navigationBarTitle("Filters") - } - } -} - -// MARK: BasicSection -private struct BasicSection: View { - @Binding private var filter: Filter - @Binding private var filterRange: FilterRange - @Binding private var resetDialogPresented: Bool - private var categoryBindings: [Binding] { [ - $filter.doujinshi, $filter.manga, $filter.artistCG, $filter.gameCG, $filter.western, - $filter.nonH, $filter.imageSet, $filter.cosplay, $filter.asianPorn, $filter.misc - ] } - - init(filter: Binding, filterRange: Binding, resetDialogPresented: Binding) { - _filter = filter - _filterRange = filterRange - _resetDialogPresented = resetDialogPresented - } - - var body: some View { - Section { - Picker("Range", selection: $filterRange) { - ForEach(FilterRange.allCases) { range in - Text(range.rawValue.localized).tag(range) - } - } - .pickerStyle(.segmented) - CategoryView(bindings: categoryBindings) - Button { - resetDialogPresented = true - } label: { - Text("Reset filters").foregroundStyle(.red) - } - Toggle("Advanced settings", isOn: $filter.advanced) - } - } -} - -// MARK: AdvancedSection -private struct AdvancedSection: View { - @Binding private var filter: Filter - - init(filter: Binding) { - _filter = filter - } - - var body: some View { - Group { - Section("Advanced".localized) { - Toggle("Search gallery name", isOn: $filter.galleryName) - Toggle("Search gallery tags", isOn: $filter.galleryTags) - Toggle("Search gallery description", isOn: $filter.galleryDesc) - Toggle("Search torrent filenames", isOn: $filter.torrentFilenames) - Toggle("Only show galleries with torrents", isOn: $filter.onlyWithTorrents) - Toggle("Search Low-Power tags", isOn: $filter.lowPowerTags) - Toggle("Search downvoted tags", isOn: $filter.downvotedTags) - Toggle("Show expunged galleries", isOn: $filter.expungedGalleries) - } - Section { - Toggle("Set minimum rating", isOn: $filter.minRatingActivated) - MinimumRatingSetter(minimum: $filter.minRating) - .disabled(!filter.minRatingActivated) - Toggle("Set pages range", isOn: $filter.pageRangeActivated) - PagesRangeSetter( - lowerBound: $filter.pageLowerBound, - upperBound: $filter.pageUpperBound - ) - .disabled(!filter.pageRangeActivated) - } - Section("Default Filter".localized) { - Toggle("Disable language filter", isOn: $filter.disableLanguage) - Toggle("Disable uploader filter", isOn: $filter.disableUploader) - Toggle("Disable tags filter", isOn: $filter.disableTags) - } - } - .disabled(!filter.advanced) - } -} - -// MARK: MinimumRatingSetter -private struct MinimumRatingSetter: View { - @Binding private var minimum: Int - - init(minimum: Binding) { - _minimum = minimum - } - - var body: some View { - HStack { - Text("Minimum rating") - Spacer() - Picker(selection: $minimum, label: Text("\(minimum) stars")) { - ForEach(Array(2...5), id: \.self) { num in - Text("\(num) stars").tag(num) - } - } - .pickerStyle(.menu) - } - } -} - -// MARK: PagesRangeSetter -private struct PagesRangeSetter: View { - @FocusState private var focusBound: FocusBound? - @Binding private var lowerBound: String - @Binding private var upperBound: String - - enum FocusBound { - case lower - case upper - } - - init(lowerBound: Binding, upperBound: Binding) { - _lowerBound = lowerBound - _upperBound = upperBound - } - - var body: some View { - HStack { - Text("Pages range") - Spacer() - SettingTextField(text: $lowerBound) - .focused($focusBound, equals: .lower) - .submitLabel(.next) - Text("-") - SettingTextField(text: $upperBound) - .focused($focusBound, equals: .upper) - .submitLabel(.done) - } - .onSubmit { - switch focusBound { - case .lower: - focusBound = .upper - case .upper: - focusBound = nil - default: - break - } - } - } -} - -// MARK: Definition -private struct TupleCategory: Identifiable { - var id: String { category.rawValue } - - let isFiltered: Binding - let category: Category -} - -enum FilterRange: String, CaseIterable, Identifiable { - var id: String { rawValue } - - case search = "Search" - case global = "Global" -} diff --git a/EhPanda/View/Home/FrontpageView.swift b/EhPanda/View/Home/FrontpageView.swift new file mode 100644 index 00000000..40c2163c --- /dev/null +++ b/EhPanda/View/Home/FrontpageView.swift @@ -0,0 +1,142 @@ +// +// FrontpageView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/18. +// + +import SwiftUI +import AlertKit +import ComposableArchitecture + +struct FrontpageView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + GenericList( + galleries: viewStore.filteredGalleries, + setting: setting, + pageNumber: viewStore.pageNumber, + loadingState: viewStore.loadingState, + footerLoadingState: viewStore.footerLoadingState, + fetchAction: { viewStore.send(.fetchGalleries()) }, + fetchMoreAction: { viewStore.send(.fetchMoreGalleries) }, + navigateAction: { viewStore.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: !setting.translatesTags) + } + ) + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /FrontpageState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: FrontpageAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /FrontpageState.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: FrontpageAction.filters)) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .jumpPageAlert( + index: viewStore.binding(\.$jumpPageIndex), + isPresented: viewStore.binding(\.$jumpPageAlertPresented), + isFocused: viewStore.binding(\.$jumpPageAlertFocused), + pageNumber: viewStore.pageNumber, + jumpAction: { viewStore.send(.performJumpPage) } + ) + .searchable(text: viewStore.binding(\.$keyword), prompt: R.string.localizable.searchablePromptFilter()) + .navigationBarBackButtonHidden(viewStore.jumpPageAlertPresented) + .animation(.default, value: viewStore.jumpPageAlertPresented) + .onAppear { + if viewStore.galleries.isEmpty { + DispatchQueue.main.async { + viewStore.send(.fetchGalleries()) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.frontpageViewTitleFrontpage()) + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /FrontpageState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: FrontpageAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + } + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(disabled: viewStore.jumpPageAlertPresented) { + ToolbarFeaturesMenu { + FiltersButton { + viewStore.send(.setNavigation(.filters)) + } + JumpPageButton(pageNumber: viewStore.pageNumber) { + viewStore.send(.presentJumpPageAlert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + viewStore.send(.setJumpPageAlertFocused(true)) + } + } + } + } + } +} + +struct FrontpageView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + FrontpageView( + store: .init( + initialState: .init(), + reducer: frontpageReducer, + environment: FrontpageEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } + } +} diff --git a/EhPanda/View/Home/HistoryView.swift b/EhPanda/View/Home/HistoryView.swift new file mode 100644 index 00000000..6301a802 --- /dev/null +++ b/EhPanda/View/Home/HistoryView.swift @@ -0,0 +1,131 @@ +// +// HistoryView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import SwiftUI +import ComposableArchitecture + +struct HistoryView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + GenericList( + galleries: viewStore.filteredGalleries, + setting: setting, + pageNumber: nil, + loadingState: viewStore.loadingState, + footerLoadingState: .idle, + fetchAction: { viewStore.send(.fetchGalleries) }, + navigateAction: { viewStore.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: !setting.translatesTags) + } + ) + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /HistoryState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: HistoryAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + } + .searchable(text: viewStore.binding(\.$keyword), prompt: R.string.localizable.searchablePromptFilter()) + .onAppear { + if viewStore.galleries.isEmpty { + DispatchQueue.main.async { + viewStore.send(.fetchGalleries) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.historyViewTitleHistory()) + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HistoryState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: HistoryAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + } + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Button { + viewStore.send(.setNavigation(.clearHistory)) + } label: { + Image(systemSymbol: .trashCircle) + } + .disabled(viewStore.loadingState != .idle || viewStore.galleries.isEmpty) + .confirmationDialog( + message: R.string.localizable.confirmationDialogTitleClear(), + unwrapping: viewStore.binding(\.$route), + case: /HistoryState.Route.clearHistory + ) { + Button(R.string.localizable.confirmationDialogButtonClear(), role: .destructive) { + viewStore.send(.clearHistoryGalleries) + } + } + } + } +} + +struct HistoryView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + HistoryView( + store: .init( + initialState: .init(), + reducer: historyReducer, + environment: HistoryEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } + } +} diff --git a/EhPanda/View/Home/Home.swift b/EhPanda/View/Home/Home.swift deleted file mode 100644 index b62dc1a7..00000000 --- a/EhPanda/View/Home/Home.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// Home.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/20. -// Copied from https://kavsoft.dev/SwiftUI_2.0/Twitter_Menu/ -// - -import SwiftUI -import Kingfisher - -struct Home: View, StoreAccessor { - @EnvironmentObject var store: Store - @Environment(\.colorScheme) private var colorScheme - - // AppLock - @State private var blurRadius: CGFloat = 0 - // SlideMenu - @State private var direction: Direction = .none - @State private var width = Defaults.FrameSize.slideMenuWidth - @State private var offset = -Defaults.FrameSize.slideMenuWidth - - var body: some View { - ZStack { - ZStack { - HomeView().offset(x: offset + width) - SlideMenu(offset: $offset).offset(x: offset).background( - Color.black.opacity(opacity).edgesIgnoringSafeArea(.vertical) - .onTapGesture { performTransition(offset: -width) } - ) - .opacity(viewControllersCount > 1 ? 0 : 1) - } - .blur(radius: blurRadius) - .allowsHitTesting(isAppUnlocked) - AuthView(blurRadius: $blurRadius) - } - .gesture(dragGesture, including: viewControllersCount == 1 ? .all : .none) - .onReceive(AppNotification.shouldHideSlideMenu.publisher) { _ in performTransition(offset: -width) } - .onReceive(AppNotification.bypassesSNIFilteringDidChange.publisher, perform: toggleDomainFronting) - .onReceive(AppNotification.shouldShowSlideMenu.publisher) { _ in performTransition(offset: 0) } - .onReceive(UIApplication.didBecomeActiveNotification.publisher, perform: tryResetWidth) - .onReceive(UIDevice.orientationDidChangeNotification.publisher) { _ in - if DeviceUtil.isPad || DeviceUtil.isLandscape { tryResetWidth() } - } - } -} - -private extension Home { - enum Direction { - case none - case toLeft - case toRight - } - - var dragGesture: some Gesture { - DragGesture() - .onChanged { value in - withAnimation(Animation.linear(duration: 0.2)) { - switch direction { - case .none: - let isToLeft = value.translation.width < 0 - direction = isToLeft ? .toLeft : .toRight - case .toLeft: - if offset > -width { - offset = min(value.translation.width, 0) - } - case .toRight: - if offset < 0, value.startLocation.x < 20 { - offset = max(-width + value.translation.width, -width) - } - } - tryUpdateSlideMenuState(value: offset == -width) - } - } - .onEnded { value in - let perdictedWidth = value.predictedEndTranslation.width - if perdictedWidth > width / 2 || -offset < width / 2 { - performTransition(offset: 0) - } - if perdictedWidth < -width / 2 || -offset > width / 2 { - performTransition(offset: -width) - } - direction = .none - } - } - - var opacity: Double { - let scale = colorScheme == .light ? 0.2 : 0.5 - return (width + offset) / width * scale - } - - func tryResetWidth(_: Any? = nil) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - if width != Defaults.FrameSize.slideMenuWidth { - withAnimation { - offset = -Defaults.FrameSize.slideMenuWidth - width = Defaults.FrameSize.slideMenuWidth - } - } - NotificationUtil.post(.appWidthDidChange) - } - } - func toggleDomainFronting(_: Any? = nil) { - if setting.bypassesSNIFiltering { - URLProtocol.registerClass(DFURLProtocol.self) - } else { - URLProtocol.unregisterClass(DFURLProtocol.self) - } - AppUtil.configureKingfisher(bypassesSNIFiltering: setting.bypassesSNIFiltering) - } - - func performTransition(offset: CGFloat) { - withAnimation { self.offset = offset } - tryUpdateSlideMenuState(value: offset == -width) - } - - func tryUpdateSlideMenuState(value: Bool) { - guard isSlideMenuClosed != value else { return } - store.dispatch(.setSlideMenuClosed(closed: value)) - } -} diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index 83fbde95..e7fe5839 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -2,708 +2,545 @@ // HomeView.swift // EhPanda // -// Created by 荒木辰造 on R 2/10/28. +// Created by 荒木辰造 on R 3/12/13. // import SwiftUI -import AlertKit -import TTProgressHUD - -struct HomeView: View, StoreAccessor { - @EnvironmentObject var store: Store - @Environment(\.colorScheme) private var colorScheme - - @AppStorage(wrappedValue: .ehentai, AppUserDefaults.galleryHost.rawValue) - var galleryHost: GalleryHost - - @State private var isSearching = false - @State private var keyword = "" - @State private var lastKeyword = "" - @State private var pendingKeywords = [String]() - - @State private var clipboardJumpID: String? - @State private var isNavLinkActive = false - @State private var greeting: Greeting? - - @State private var hudVisible = false - @State private var hudConfig = TTProgressHUDConfig() - - @State private var alertInput = "" - @FocusState private var isAlertFocused: Bool - @StateObject private var alertManager = CustomAlertManager() - @State private var clearHistoryDialogPresented = false +import Kingfisher +import SwiftUIPager +import SFSafeSymbols +import ComposableArchitecture + +struct HomeView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } // MARK: HomeView var body: some View { NavigationView { ZStack { - conditionalList - SearchHelper(isSearching: $isSearching) - TTProgressHUD($hudVisible, config: hudConfig) - } - .background { - NavigationLink( - "", - destination: DetailView(gid: clipboardJumpID ?? ""), - isActive: $isNavLinkActive - ) - } - .searchable( - text: $keyword, placement: .navigationBarDrawer(displayMode: .always) - ) { SuggestionProvider(keyword: $keyword) } - .navigationBarTitle(navigationBarTitle) - .onSubmit(of: .search, performSearch) - .toolbar(content: toolbar) - } - .navigationViewStyle(.stack) - .onOpenURL(perform: tryOpenURL).onAppear(perform: onStartTasks) - .sheet(item: environmentBinding.homeViewSheetState, content: sheet) - .onReceive(UIApplication.didBecomeActiveNotification.publisher, perform: onBecomeActive) - .onChange(of: environment.galleryItemReverseLoading, perform: tryDismissLoadingHUD) - .onChange(of: currentListTypePageNumber) { alertInput = String($0.current + 1) } - .onChange(of: environment.galleryItemReverseID, perform: tryActivateNavLink) - .onChange(of: environment.favoritesIndex) { _ in tryFetchFavoritesItems() } - .onChange(of: environment.toplistsType) { _ in tryFetchToplistsItems() } - .onChange(of: alertManager.isPresented) { _ in isAlertFocused = false } - .onChange(of: environment.homeListType, perform: onHomeListTypeChange) - .onChange(of: user.greeting, perform: tryPresentNewDawnSheet) - .onChange(of: isSearching, perform: tryUpdateHistoryKeywords) - .onChange(of: galleryHost) { _ in - CookiesUtil.removeYay() - store.dispatch(.verifyEhProfile) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - store.dispatch(.resetHomeInfo) - } - } - .customAlert( - manager: alertManager, widthFactor: DeviceUtil.isPadWidth ? 0.5 : 1.0, - backgroundOpacity: colorScheme == .light ? 0.2 : 0.5, - content: { - PageJumpView( - inputText: $alertInput, isFocused: $isAlertFocused, - pageNumber: currentListTypePageNumber - ) - }, - buttons: [.regular(content: { Text("Confirm") }, action: tryPerformJumpPage)] - ) - .confirmationDialog( - "Are you sure to clear?", isPresented: $clearHistoryDialogPresented, titleVisibility: .visible - ) { - Button("Clear", role: .destructive, action: PersistenceController.clearGalleryHistory) - } - } -} - -private extension HomeView { - // MARK: Sheet - func sheet(item: HomeViewSheetState) -> some View { - Group { - switch item { - case .setting: - SettingView().tint(accentColor) - case .filter: - FilterView().tint(accentColor) - case .newDawn: - NewDawnView(greeting: greeting) - case .quickSearch: - QuickSearchView(searchAction: performQuickSearch) - } - } - .accentColor(accentColor) - .blur(radius: environment.blurRadius) - .allowsHitTesting(environment.isAppUnlocked) - } - - // MARK: Toolbar - func toolbar() -> some ToolbarContent { - func selectIndexMenu() -> some View { - Menu { - if environment.homeListType == .favorites { - ForEach(-1..<10) { index in - Button { - guard index != environment.favoritesIndex else { return } - store.dispatch(.setFavoritesIndex(index)) - } label: { - Text(User.getFavNameFrom(index: index, names: favoriteNames)) - if index == environment.favoritesIndex { - Image(systemName: "checkmark") - } + ScrollView(showsIndicators: false) { + VStack { + if viewStore.popularLoadingState == .loading || !viewStore.popularGalleries.isEmpty { + CardSlideSection( + galleries: viewStore.popularGalleries, + pageIndex: viewStore.binding(\.$cardPageIndex), + currentID: viewStore.currentCardID, + colors: viewStore.cardColors, + navigateAction: navigateTo(gid:), + webImageSuccessAction: { gid, result in + viewStore.send(.analyzeImageColors(gid, result)) + } + ) + .equatable().allowsHitTesting(viewStore.allowsCardHitTesting) } - } - } else if environment.homeListType == .toplists { - ForEach(ToplistsType.allCases) { type in - Button { - guard type != environment.toplistsType else { return } - store.dispatch(.setToplistsType(type)) - } label: { - Text(type.description.localized) - if type == environment.toplistsType { - Image(systemName: "checkmark") + Group { + if viewStore.frontpageLoadingState == .loading || viewStore.frontpageGalleries.count > 1 { + CoverWallSection( + galleries: viewStore.frontpageGalleries, + isLoading: viewStore.frontpageLoadingState == .loading, + navigateAction: navigateTo(gid:), + showAllAction: { viewStore.send(.setNavigation(.section(.frontpage))) }, + reloadAction: { viewStore.send(.fetchFrontpageGalleries()) } + ) } + ToplistsSection( + galleries: viewStore.toplistsGalleries, + isLoading: !viewStore.toplistsLoadingState + .values.allSatisfy({ $0 != .loading }), + navigateAction: navigateTo(gid:), + showAllAction: { viewStore.send(.setNavigation(.section(.toplists))) }, + reloadAction: { viewStore.send(.fetchAllToplistsGalleries) } + ) + MiscGridSection(navigateAction: navigateTo(type:)) } + .padding(.vertical) } } - } label: { - Image(systemName: "dial.min") - .symbolRenderingMode(.hierarchical) - .foregroundColor(.primary) - } - .opacity([.favorites, .toplists].contains(environment.homeListType) ? 1 : 0) - } - func sortOrderMenu() -> some View { - Menu { - ForEach(FavoritesSortOrder.allCases) { order in - Button { - guard order != environment.favoritesSortOrder else { return } - store.dispatch(.fetchFavoritesItems(sortOrder: order)) - } label: { - Text(order.value.localized) - if order == environment.favoritesSortOrder { - Image(systemName: "checkmark") - } - } + .opacity(viewStore.popularGalleries.isEmpty ? 0 : 1).zIndex(2) + LoadingView() + .opacity( + viewStore.popularLoadingState == .loading + && viewStore.popularGalleries.isEmpty ? 1 : 0 + ) + .zIndex(0) + let error = (/LoadingState.failed).extract(from: viewStore.popularLoadingState) + ErrorView(error: error ?? .unknown) { + viewStore.send(.fetchAllGalleries) } - } label: { - Image(systemName: "arrow.up.arrow.down.circle") - .symbolRenderingMode(.hierarchical) - .foregroundColor(.primary) + .opacity(viewStore.popularGalleries.isEmpty && error != nil ? 1 : 0) + .zIndex(1) } - } - func moreFeaturesMenu() -> some View { - Menu { - Button { - store.dispatch(.setHomeViewSheetState(.filter)) - } label: { - Image(systemName: "line.3.horizontal.decrease") - Text("Filters") - } - Button { - store.dispatch(.setHomeViewSheetState(.quickSearch)) - } label: { - Image(systemName: "magnifyingglass") - Text("Quick search") - } - Button(action: presentJumpPageAlert) { - Image(systemName: "arrowshape.bounce.forward") - Text("Jump page") + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /HomeState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: HomeAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) } - .disabled(currentListTypePageNumber.isSinglePage) - if environment.homeListType == .history { - Button { - clearHistoryDialogPresented = true - } label: { - Image(systemName: "trash") - Text("Clear history") - } - .disabled(galleryHistory.isEmpty) - } - } label: { - Image(systemName: "ellipsis.circle") - .symbolRenderingMode(.hierarchical) - .foregroundColor(.primary) + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) } - } - return Group { - ToolbarItem(placement: .navigationBarLeading) { - Button { - NotificationUtil.post(.shouldShowSlideMenu) - } label: { - Image(systemName: "line.3.horizontal") - .foregroundColor(.secondary) - } - } - ToolbarItem(placement: .navigationBarTrailing) { - HStack { - selectIndexMenu() - if environment.homeListType == .favorites { - sortOrderMenu() - } - moreFeaturesMenu() + .animation(.default, value: viewStore.popularLoadingState) + .onAppear { + if viewStore.popularGalleries.isEmpty { + viewStore.send(.fetchAllGalleries) } } + .background(navigationLinks) + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.homeViewTitleHome()) } } - // MARK: List - @ViewBuilder var conditionalList: some View { - switch environment.homeListType { - case .search: - GenericList( - items: homeInfo.searchItems, - setting: setting, - pageNumber: homeInfo.searchPageNumber, - loadingFlag: homeInfo.searchLoading, - loadError: homeInfo.searchLoadError, - moreLoadingFlag: homeInfo.moreSearchLoading, - moreLoadFailedFlag: homeInfo.moreSearchLoadFailed, - fetchAction: tryRefetchSearchItems, - loadMoreAction: fetchMoreSearchItems, - translateAction: tryTranslateTag - ) - case .frontpage: - GenericList( - items: homeInfo.frontpageItems, - setting: setting, - pageNumber: homeInfo.frontpagePageNumber, - loadingFlag: homeInfo.frontpageLoading, - loadError: homeInfo.frontpageLoadError, - moreLoadingFlag: homeInfo.moreFrontpageLoading, - moreLoadFailedFlag: homeInfo.moreFrontpageLoadFailed, - fetchAction: fetchFrontpageItems, - loadMoreAction: fetchMoreFrontpageItems, - translateAction: tryTranslateTag - ) - case .popular: - GenericList( - items: homeInfo.popularItems, - setting: setting, - pageNumber: nil, - loadingFlag: homeInfo.popularLoading, - loadError: homeInfo.popularLoadError, - moreLoadingFlag: false, - moreLoadFailedFlag: false, - fetchAction: fetchPopularItems, - translateAction: tryTranslateTag - ) - case .watched: - GenericList( - items: homeInfo.watchedItems, - setting: setting, - pageNumber: homeInfo.watchedPageNumber, - loadingFlag: homeInfo.watchedLoading, - loadError: homeInfo.watchedLoadError, - moreLoadingFlag: homeInfo.moreWatchedLoading, - moreLoadFailedFlag: homeInfo.moreWatchedLoadFailed, - fetchAction: fetchWatchedItems, - loadMoreAction: fetchMoreWatchedItems, - translateAction: tryTranslateTag - ) - case .favorites: - GenericList( - items: homeInfo.favoritesItems[environment.favoritesIndex] ?? [], setting: setting, - pageNumber: homeInfo.favoritesPageNumbers[environment.favoritesIndex], - loadingFlag: homeInfo.favoritesLoading[environment.favoritesIndex] ?? false, - loadError: homeInfo.favoritesLoadErrors[environment.favoritesIndex], - moreLoadingFlag: homeInfo.moreFavoritesLoading[environment.favoritesIndex] ?? false, - moreLoadFailedFlag: homeInfo.moreFavoritesLoadFailed[environment.favoritesIndex] ?? false, - fetchAction: fetchFavoritesItems, loadMoreAction: fetchMoreFavoritesItems, - translateAction: tryTranslateTag - ) - case .toplists: - GenericList( - items: homeInfo.toplistsItems[environment.toplistsType.rawValue] ?? [], setting: setting, - pageNumber: homeInfo.toplistsPageNumbers[environment.toplistsType.rawValue], - loadingFlag: homeInfo.toplistsLoading[environment.toplistsType.rawValue] ?? false, - loadError: homeInfo.toplistsLoadErrors[environment.toplistsType.rawValue], - moreLoadingFlag: homeInfo.moreToplistsLoading[environment.toplistsType.rawValue] ?? false, - moreLoadFailedFlag: homeInfo.moreToplistsLoadFailed[environment.toplistsType.rawValue] ?? false, - fetchAction: fetchToplistsItems, loadMoreAction: fetchMoreToplistsItems, - translateAction: tryTranslateTag - ) - case .downloaded: - ErrorView(error: .notFound, retryAction: nil) - case .history: - GenericList( - items: galleryHistory, - setting: setting, - pageNumber: nil, - loadingFlag: false, - loadError: galleryHistory.isEmpty ? .notFound : nil, - moreLoadingFlag: false, - moreLoadFailedFlag: false, - translateAction: tryTranslateTag - ) + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(tint: .primary) { + Button { + viewStore.send(.fetchAllGalleries) + } label: { + Image(systemSymbol: .arrowCounterclockwise) + } + .opacity(viewStore.popularLoadingState == .loading ? 0 : 1) + .overlay(ProgressView().opacity(viewStore.popularLoadingState == .loading ? 1 : 0)) } } } -// MARK: Private Properties +// MARK: NavigationLinks private extension HomeView { - var galleryHistory: [Gallery] { - PersistenceController.fetchGalleryHistory() - } - var environmentBinding: Binding { - $store.appState.environment - } - var homeInfoBinding: Binding { - $store.appState.homeInfo - } - - var hasJumpPermission: Bool { - detectsLinksFromPasteboard && viewControllersCount == 1 - } - var navigationBarTitle: String { - if environment.favoritesIndex != -1, environment.homeListType == .favorites { - return settings.user.getFavNameFrom(index: environment.favoritesIndex) - } else { - return environment.homeListType.rawValue.localized + @ViewBuilder var navigationLinks: some View { + if DeviceUtil.isPhone { + detailViewLink + } + miscGridLink + sectionLink + } + var detailViewLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: HomeAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) } } - var pasteboardURL: URL? { - let currentChangeCount = UIPasteboard.general.changeCount - if PasteboardUtil.changeCount != currentChangeCount { - PasteboardUtil.setChangeCount(value: currentChangeCount) - return PasteboardUtil.url - } else { - return nil + var miscGridLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeState.Route.misc) { route in + switch route.wrappedValue { + case .popular: + PopularView( + store: store.scope(state: \.popularState, action: HomeAction.popular), + user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .watched: + WatchedView( + store: store.scope(state: \.watchedState, action: HomeAction.watched), + user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .history: + HistoryView( + store: store.scope(state: \.historyState, action: HomeAction.history), + user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } } } - var currentListTypePageNumber: PageNumber { - switch environment.homeListType { - case .search: - return homeInfo.searchPageNumber - case .frontpage: - return homeInfo.frontpagePageNumber - case .watched: - return homeInfo.watchedPageNumber - case .favorites: - let index = environment.favoritesIndex - return homeInfo.favoritesPageNumbers[index] ?? PageNumber() - case .toplists: - let index = environment.toplistsType.rawValue - return homeInfo.toplistsPageNumbers[index] ?? PageNumber() - case .popular, .downloaded, .history: - return PageNumber() + var sectionLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeState.Route.section) { route in + switch route.wrappedValue { + case .frontpage: + FrontpageView( + store: store.scope(state: \.frontpageState, action: HomeAction.frontpage), + user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator + ) + case .toplists: + ToplistsView( + store: store.scope(state: \.toplistsState, action: HomeAction.toplists), + user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } } } + func navigateTo(gid: String) { + viewStore.send(.setNavigation(.detail(gid))) + } + func navigateTo(type: HomeMiscGridType) { + viewStore.send(.setNavigation(.misc(type))) + } } -private extension HomeView { - // MARK: Life Cycle - func onStartTasks() { - tryOpenPasteboardURL() - tryFetchGreeting() - tryFetchFrontpageItems() - } - func onBecomeActive(_: Any? = nil) { - guard viewControllersCount == 1 else { return } - tryOpenPasteboardURL() - tryFetchGreeting() - } - func onHomeListTypeChange(type: HomeListType) { - switch type { - case .frontpage: - tryFetchFrontpageItems() - case .popular: - guard homeInfo.popularItems.isEmpty else { return } - fetchPopularItems() - case .watched: - guard homeInfo.watchedItems.isEmpty else { return } - fetchWatchedItems() - case .favorites: - tryFetchFavoritesItems() - case .toplists: - tryFetchToplistsItems() - case .downloaded, .search, .history: - return - } +// 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 } - func tryPresentNewDawnSheet(newValue: Greeting?) { - guard setting.showNewDawnGreeting, let greeting = newValue, !greeting.gainedNothing else { return } - self.greeting = greeting - if environment.homeViewSheetState == nil { - store.dispatch(.setHomeViewSheetState(.newDawn)) - } else { - store.dispatch(.setHomeViewSheetState(nil)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - store.dispatch(.setHomeViewSheetState(.newDawn)) - } - } + static func == (lhs: CardSlideSection, rhs: CardSlideSection) -> Bool { + lhs.galleries == rhs.galleries + && lhs.currentID == rhs.currentID + && lhs.colors == rhs.colors } - // MARK: Navigation(handleURL) - func tryOpenURL(_ url: URL) { - guard let scheme = url.scheme else { return } - let replacedString = url.absoluteString - .replacingOccurrences(of: scheme, with: "https") - guard let replacedURL = URL(string: replacedString) else { return } - - handleURL(replacedURL) - } - func tryOpenPasteboardURL() { - guard hasJumpPermission, let url = pasteboardURL else { return } - handleURL(url) - } - func handleURL(_ url: URL) { - let shouldDelayDisplay = homeInfo.frontpageItems.isEmpty - URLUtil.handleURL(url) { shouldParseGalleryURL, incomingURL, pageIndex, commentID in - guard let incomingURL = incomingURL else { return } - - let gid = URLUtil.parseGID(url: incomingURL, isGalleryURL: shouldParseGalleryURL) - store.dispatch(.setPendingJumpInfos( - gid: gid, pageIndex: pageIndex, commentID: commentID - )) - - if PersistenceController.galleryCached(gid: gid) { - replaceGalleryCommentJumpID(gid: gid) - } else { - if shouldDelayDisplay { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - store.dispatch(.fetchGalleryItemReverse( - url: incomingURL.absoluteString, - shouldParseGalleryURL: shouldParseGalleryURL - )) - presentLoadingHUD() - } - } else { - store.dispatch(.fetchGalleryItemReverse( - url: incomingURL.absoluteString, - shouldParseGalleryURL: shouldParseGalleryURL - )) - presentLoadingHUD() + 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) } - PasteboardUtil.clear() - clearObstruction() } + .preferredItemSize(Defaults.FrameSize.cardCellSize) + .interactive(opacity: 0.2).itemSpacing(20) + .loopPages().pagingPriority(.high) + .synchronize($pageIndex, $page.index) + .frame(height: Defaults.FrameSize.cardCellHeight) } - // Removing this could cause unexpected blank leading space - func clearObstruction() { - if environment.homeViewSheetState != nil { - store.dispatch(.setHomeViewSheetState(nil)) +} + +// 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 !environment.slideMenuClosed { - NotificationUtil.post(.shouldHideSlideMenu) + 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]] } } - // MARK: Navigation(other) - func presentLoadingHUD() { - hudConfig = TTProgressHUDConfig(type: .loading, title: "Loading...".localized) - hudVisible = true - } - func tryDismissLoadingHUD(newValue: Bool) { - guard !newValue, hasJumpPermission else { return } - hudVisible = false - hudConfig = TTProgressHUDConfig() + var body: some View { + SubSection( + title: R.string.localizable.homeViewSectionTitleFrontpage(), + 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) + } } - func replaceGalleryCommentJumpID(gid: String?) { - store.dispatch(.setGalleryCommentJumpID(gid: gid)) +} + +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 } - func presentJumpPageAlert() { - alertManager.show() - isAlertFocused = true - HapticUtil.generateFeedback(style: .light) + + private func placeholder() -> some View { + Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) } - func tryPerformJumpPage() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard let index = Int(alertInput), index <= currentListTypePageNumber.maximum + 1 else { return } - store.dispatch(.handleJumpPage(index: index - 1, keyword: lastKeyword)) + 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) } } - func tryActivateNavLink(newValue: String?) { - guard newValue != nil, hasJumpPermission else { return } - clipboardJumpID = newValue - isNavLinkActive = true - replaceGalleryCommentJumpID(gid: nil) + + var body: some View { + VStack(spacing: 20) { + ForEach(galleries, content: imageContainer) + } } +} - // MARK: Search - func tryUpdateHistoryKeywords(isSearching: Bool) { - guard !isSearching, !lastKeyword.isEmpty else { return } - store.dispatch(.appendHistoryKeywords(texts: pendingKeywords)) - pendingKeywords = [] +// 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 } - func tryRefetchSearchItems() { - guard !lastKeyword.isEmpty else { return } - store.dispatch(.fetchSearchItems(keyword: lastKeyword)) + private func galleries(type: ToplistsType, range: ClosedRange) -> [Gallery] { + let galleries = dataSource[type.categoryIndex] ?? [] + guard galleries.count > range.upperBound else { return [] } + return Array(galleries[range]) } - func performSearch() { - if environment.homeListType != .search { - store.dispatch(.setHomeListType(.search)) - } - if !keyword.isEmpty { - pendingKeywords.append(keyword) - lastKeyword = keyword + + var body: some View { + SubSection( + title: R.string.localizable.homeViewSectionTitleToplists(), + tint: .secondary, isLoading: isLoading, + reloadAction: reloadAction, + showAllAction: showAllAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(ToplistsType.allCases, content: verticalStacks) + } + } } - store.dispatch(.fetchSearchItems(keyword: keyword)) } - func performQuickSearch(keyword: String) { - store.dispatch(.setHomeViewSheetState(.none)) - self.keyword = keyword - performSearch() + 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) } +} - // MARK: Tools - func tryTranslateTag(text: String) -> String { - guard setting.translatesTags else { return text } - let translator = settings.tagTranslator +private struct VerticalToplistsStack: View { + private let galleries: [Gallery] + private let startRanking: Int + private let navigateAction: (String) -> Void - if let range = text.range(of: ":") { - let before = text[...range.lowerBound] - let after = String(text[range.upperBound...]) - let result = before + translator.translate(text: after) - return String(result) - } - return translator.translate(text: text) + init(galleries: [Gallery], startRanking: Int, navigateAction: @escaping (String) -> Void) { + self.galleries = galleries + self.startRanking = startRanking + self.navigateAction = navigateAction } - func tryFetchGreeting() { - func verifyDate(with updateTime: Date?) -> Bool { - guard let updateTime = updateTime else { return false } - - 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 + var body: some View { + VStack(spacing: 10) { + ForEach(0.. Void - func fetchMoreSearchItems() { - store.dispatch(.fetchMoreSearchItems(keyword: lastKeyword)) - } - func fetchMoreFrontpageItems() { - store.dispatch(.fetchMoreFrontpageItems) - } - func fetchMoreWatchedItems() { - store.dispatch(.fetchMoreWatchedItems) - } - func fetchMoreFavoritesItems() { - store.dispatch(.fetchMoreFavoritesItems) - } - func fetchMoreToplistsItems() { - store.dispatch(.fetchMoreToplistsItems) + init(navigateAction: @escaping (HomeMiscGridType) -> Void) { + self.navigateAction = navigateAction } - func tryFetchFrontpageItems() { - guard homeInfo.frontpageItems.isEmpty else { return } - fetchFrontpageItems() - } - func tryFetchFavoritesItems() { - guard homeInfo.favoritesItems[environment.favoritesIndex]?.isEmpty != false else { return } - fetchFavoritesItems() - } - func tryFetchToplistsItems() { - guard homeInfo.toplistsItems[environment.toplistsType.rawValue]?.isEmpty != false else { return } - fetchToplistsItems() + var body: some View { + SubSection(title: R.string.localizable.homeViewSectionTitleOther(), 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() + } + } + } } } -// MARK: SearchHelper -private struct SearchHelper: View { - @Environment(\.isSearching) var isSearchingEnvironment - @Binding var isSearching: Bool +private struct MiscGridItem: View { + private let title: String + private let subTitle: String? + private let symbol: SFSymbol - init(isSearching: Binding) { - _isSearching = isSearching + init(title: String, subTitle: String? = nil, symbol: SFSymbol) { + self.title = title + self.subTitle = subTitle + self.symbol = symbol } var body: some View { - Text("").onChange(of: isSearchingEnvironment) { newValue in - isSearching = newValue + 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 HomeListType: String, Identifiable, CaseIterable { - var id: Int { hashValue } - - case search = "Search" - case frontpage = "Frontpage" - case popular = "Popular" - case watched = "Watched" - case favorites = "Favorites" - case toplists = "Toplists" - case downloaded = "Downloaded" - case history = "History" - - var symbolName: String { +enum HomeMiscGridType: CaseIterable, Identifiable { + var id: String { title } + + case popular + case watched + case history +} + +extension HomeMiscGridType { + var title: String { + switch self { + case .popular: + return R.string.localizable.homeMiscGridTypeTitlePopular() + case .watched: + return R.string.localizable.homeMiscGridTypeTitleWatched() + case .history: + return R.string.localizable.homeMiscGridTypeTitleHistory() + } + } + var symbol: SFSymbol { switch self { - case .search: - return "magnifyingglass.circle" - case .frontpage: - return "house" case .popular: - return "flame" + return .flame case .watched: - return "tag.circle" - case .favorites: - return "heart.circle" - case .toplists: - return "list.bullet.circle" - case .downloaded: - return "arrow.down.circle" + return .tagCircle case .history: - return "clock.arrow.circlepath" + return .clockArrowCirclepath } } } -enum HomeViewSheetState: Identifiable { - var id: Int { hashValue } - - case setting - case filter - case newDawn - case quickSearch -} +enum HomeSectionType: String, CaseIterable, Identifiable { + var id: String { rawValue } -enum ToplistsType: Int, Codable, CaseIterable, Identifiable { - case allTime - case pastYear - case pastMonth - case yesterday + case frontpage + case toplists } -extension ToplistsType { - var id: Int { description.hashValue } - - var description: String { - switch self { - case .allTime: - return "All time" - case .pastYear: - return "Past year" - case .pastMonth: - return "Past month" - case .yesterday: - return "Yesterday" - } - } - var categoryIndex: Int { - switch self { - case .allTime: - return 11 - case .pastYear: - return 12 - case .pastMonth: - return 13 - case .yesterday: - return 15 - } +struct HomeView_Previews: PreviewProvider { + static var previews: some View { + HomeView( + store: .init( + initialState: .init(), + reducer: homeReducer, + environment: HomeEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + libraryClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) } } diff --git a/EhPanda/View/Home/PopularView.swift b/EhPanda/View/Home/PopularView.swift new file mode 100644 index 00000000..12dc1ea0 --- /dev/null +++ b/EhPanda/View/Home/PopularView.swift @@ -0,0 +1,122 @@ +// +// PopularView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import SwiftUI +import ComposableArchitecture + +struct PopularView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + GenericList( + galleries: viewStore.filteredGalleries, + setting: setting, pageNumber: nil, + loadingState: viewStore.loadingState, + footerLoadingState: .idle, + fetchAction: { viewStore.send(.fetchGalleries) }, + navigateAction: { viewStore.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: !setting.translatesTags) + } + ) + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /PopularState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: PopularAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /PopularState.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: PopularAction.filters)) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .searchable(text: viewStore.binding(\.$keyword), prompt: R.string.localizable.searchablePromptFilter()) + .onAppear { + if viewStore.galleries.isEmpty { + DispatchQueue.main.async { + viewStore.send(.fetchGalleries) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.popularViewTitlePopular()) + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /PopularState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: PopularAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + } + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + FiltersButton(hideText: true) { + viewStore.send(.setNavigation(.filters)) + } + } + } +} + +struct PopularView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + PopularView( + store: .init( + initialState: .init(), + reducer: popularReducer, + environment: PopularEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } + } +} diff --git a/EhPanda/View/Home/QuickSearchView.swift b/EhPanda/View/Home/QuickSearchView.swift deleted file mode 100644 index e7f3501c..00000000 --- a/EhPanda/View/Home/QuickSearchView.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// QuickSearchView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/09/25. -// - -import SwiftUI - -struct QuickSearchView: View, StoreAccessor { - @EnvironmentObject var store: Store - @State private var isEditting = false - @State private var refreshTrigger = UUID().uuidString - - private let searchAction: (String) -> Void - - init(searchAction: @escaping (String) -> Void) { - self.searchAction = searchAction - } - - // MARK: QuickSearchView - var body: some View { - NavigationView { - ZStack { - List { - ForEach(words) { word in - QuickSearchWordRow( - word: word, isEditting: $isEditting, - refreshTrigger: $refreshTrigger, searchAction: searchAction, - submitAction: { store.dispatch(.modifyQuickSearchWord(newWord: $0)) } - ) - } - .onDelete { store.dispatch(.deleteQuickSearchWord(offsets: $0)) } - .onMove(perform: move) - } - .id(refreshTrigger) - ErrorView(error: .notFound, retryAction: nil).opacity(words.isEmpty ? 1 : 0) - } - .environment(\.editMode, .constant(isEditting ? .active : .inactive)) - .toolbar(content: toolbar).navigationTitle("Quick search") - } - } - - // MARK: Toolbar - private func toolbar() -> some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - HStack { - Button { - store.dispatch(.appendQuickSearchWord) - } label: { - Image(systemName: "plus") - } - .opacity(isEditting ? 1 : 0) - Button { - isEditting.toggle() - } label: { - Image(systemName: "pencil.circle") - .symbolVariant(isEditting ? .fill : .none) - } - } - } - } -} - -private extension QuickSearchView { - var words: [QuickSearchWord] { - homeInfo.quickSearchWords - } - func move(from source: IndexSet, to destination: Int) { - refreshTrigger = UUID().uuidString - store.dispatch(.moveQuickSearchWord(source: source, destination: destination)) - } -} - -// MARK: QuickSearchWordRow -private struct QuickSearchWordRow: View { - @FocusState private var focusField: FocusField? - @State private var editableAlias: String - @State private var editableContent: String - private var plainWord: QuickSearchWord - @Binding private var isEditting: Bool - @Binding private var refreshTrigger: String - private var searchAction: (String) -> Void - private var submitAction: (QuickSearchWord) -> Void - - enum FocusField { - case alias - case content - } - - init( - word: QuickSearchWord, - isEditting: Binding, - refreshTrigger: Binding, - searchAction: @escaping (String) -> Void, - submitAction: @escaping (QuickSearchWord) -> Void - ) { - _editableAlias = State(initialValue: word.alias ?? "") - _editableContent = State(initialValue: word.content) - - plainWord = word - _isEditting = isEditting - _refreshTrigger = refreshTrigger - self.searchAction = searchAction - self.submitAction = submitAction - } - - private var title: String { - if let alias = plainWord.alias, !alias.isEmpty { - return alias - } else { - return plainWord.content - } - } - - var body: some View { - ZStack { - if isEditting { - VStack { - TextField(editableAlias, text: $editableAlias, prompt: Text("Alias")) - .submitLabel(.next).lineLimit(1).focused($focusField, equals: .alias) - Divider().foregroundColor(.secondary.opacity(0.2)) - TextEditor(text: $editableContent).textInputAutocapitalization(.none) - .disableAutocorrection(true).focused($focusField, equals: .content) - } - } else { - Button(title) { - searchAction(plainWord.content) - } - .withArrow().foregroundColor(.primary) - } - } - .onChange(of: isEditting) { _ in focusField = nil } - .onChange(of: refreshTrigger, perform: trySubmit) - .onChange(of: focusField, perform: trySubmit) - .onSubmit { - switch focusField { - case .alias: - focusField = .content - default: - focusField = nil - } - } - } - - private func trySubmit(_: Any? = nil) { - var newWord = QuickSearchWord(id: plainWord.id, content: editableContent) - if !editableAlias.isEmpty { newWord.alias = editableAlias } - guard newWord != plainWord else { return } - submitAction(newWord) - } -} - -struct QuickSearchView_Previews: PreviewProvider { - static var previews: some View { - QuickSearchView(searchAction: { _ in }) - .environmentObject(Store.preview) - } -} diff --git a/EhPanda/View/Home/SlideMenu.swift b/EhPanda/View/Home/SlideMenu.swift deleted file mode 100644 index b155c0fd..00000000 --- a/EhPanda/View/Home/SlideMenu.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// SlideMenu.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/02/18. -// - -import SwiftUI -import Kingfisher - -struct SlideMenu: View, StoreAccessor { - @EnvironmentObject var store: Store - @Environment(\.colorScheme) private var colorScheme - - @Binding private var offset: CGFloat - private var edges = DeviceUtil.keyWindow?.safeAreaInsets - - private var menuItems: [HomeListType] { - let excludedType: [HomeListType] = AuthorizationUtil.didLogin ? [.search, .downloaded] - : [.search, .watched, .favorites, .downloaded] - return HomeListType.allCases.filter { !excludedType.contains($0) } - } - - init(offset: Binding) { - _offset = offset - } - - // MARK: SlideMenu - var body: some View { - HStack(spacing: 0) { - VStack(spacing: 0) { - AvatarView( - iconName: setting.appIconType.iconName, - avatarURL: user.avatarURL, - displayName: user.displayName, - width: Defaults.ImageSize.avatarW, - height: Defaults.ImageSize.avatarH - ) - .padding(.top, 40).padding(.bottom, 20) - Divider().padding(.vertical) - ScrollView(showsIndicators: false) { - ForEach(menuItems) { item in - MenuRow( - isSelected: item == homeListType, - symbolName: item.symbolName, - text: item.rawValue, - action: { trySetHomeListType(item: item) } - ) - } - } - Divider().padding(.vertical) - MenuRow(isSelected: false, symbolName: "gear", text: "Setting") { - store.dispatch(.setHomeViewSheetState(.setting)) - } - } - .padding(.horizontal, 20) - .padding(.top, edges?.top == 0 ? 15 : edges?.top) - .padding(.bottom, edges?.bottom == 0 ? 15 : edges?.bottom) - .frame(width: Defaults.FrameSize.slideMenuWidth) - .background(colorScheme == .light ? .white : .black) - .edgesIgnoringSafeArea(.vertical) - - Spacer() - } - } - private func trySetHomeListType(item: HomeListType) { - guard homeListType != item else { return } - HapticUtil.generateFeedback(style: .soft) - store.dispatch(.setHomeListType(item)) - withAnimation { offset = -Defaults.FrameSize.slideMenuWidth } - } -} - -// MARK: AvatarView -private struct AvatarView: View { - private let iconName: String - private let avatarURL: String? - private let displayName: String? - - private let width: CGFloat - private let height: CGFloat - - init( - iconName: String, - avatarURL: String?, - displayName: String?, - width: CGFloat, - height: CGFloat - ) { - self.iconName = iconName - self.avatarURL = avatarURL - self.displayName = displayName - self.width = width - self.height = height - } - - var body: some View { - HStack { - VStack(alignment: .leading) { - Group { - if avatarURL?.contains(".gif") != true { - KFImage(URL(string: avatarURL ?? "")) - .placeholder(placeholder).retry(maxCount: 10) - .defaultModifier(withRoundedCorners: false) - } else { - KFAnimatedImage(URL(string: avatarURL ?? "")) - .placeholder(placeholder) - .fade(duration: 0.25) - .retry(maxCount: 10) - } - } - .scaledToFill().frame(width: width, height: height) - .clipShape(Circle()) - Text(displayName ?? "Sad Panda") - .fontWeight(.bold) - .font(.largeTitle) - .lineLimit(1) - } - Spacer() - } - } - private func placeholder() -> some View { - Image(iconName).resizable() - } -} - -// MARK: MenuRow -private struct MenuRow: View { - @Environment(\.colorScheme) private var colorScheme - @State private var isPressing = false - - private let isSelected: Bool - private let symbolName: String - private let text: String - private let action: () -> Void - - private var textColor: Color { - guard !isSelected else { return .primary } - return colorScheme == .light ? Color(.darkGray) : Color(.lightGray) - } - private var backgroundColor: Color { - let selectedColor = Color(.systemGray6) - let pressingColor = selectedColor.opacity(0.6) - - guard !isSelected else { return selectedColor } - return isPressing ? pressingColor : .clear - } - - init( - isSelected: Bool, - symbolName: String, - text: String, - action: @escaping () -> Void - ) { - self.isSelected = isSelected - self.symbolName = symbolName - self.text = text - self.action = action - } - - var body: some View { - VStack { - HStack { - Image(systemName: symbolName) - .symbolVariant(isSelected ? .fill : .none) - .font(.title).frame(width: 35) - .foregroundColor(textColor) - .padding(.trailing, 20) - Text(text.localized) - .fontWeight(.medium) - .foregroundColor(textColor) - .font(.headline) - Spacer() - } - .contentShape(Rectangle()) - .padding(.vertical, 10) - .padding(.horizontal, 20) - .background(backgroundColor) - .cornerRadius(10) - .onTapGesture(perform: action) - .onLongPressGesture( - minimumDuration: .infinity, - maximumDistance: 50, - pressing: { isPressing = $0 }, - perform: {} - ) - } - } -} diff --git a/EhPanda/View/Home/ToplistsView.swift b/EhPanda/View/Home/ToplistsView.swift new file mode 100644 index 00000000..48a8751a --- /dev/null +++ b/EhPanda/View/Home/ToplistsView.swift @@ -0,0 +1,178 @@ +// +// ToplistsView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import SwiftUI +import ComposableArchitecture + +struct ToplistsView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + private var navigationTitle: String { + [R.string.localizable.toplistsViewTitleToplists(), viewStore.type.value].joined(separator: " - ") + } + + var body: some View { + GenericList( + galleries: viewStore.filteredGalleries ?? [], + setting: setting, + pageNumber: viewStore.pageNumber, + loadingState: viewStore.loadingState ?? .idle, + footerLoadingState: viewStore.footerLoadingState ?? .idle, + fetchAction: { viewStore.send(.fetchGalleries()) }, + fetchMoreAction: { viewStore.send(.fetchMoreGalleries) }, + navigateAction: { viewStore.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: setting.translatesTags) + } + ) + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /ToplistsState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: ToplistsAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + } + .jumpPageAlert( + index: viewStore.binding(\.$jumpPageIndex), + isPresented: viewStore.binding(\.$jumpPageAlertPresented), + isFocused: viewStore.binding(\.$jumpPageAlertFocused), + pageNumber: viewStore.pageNumber ?? .init(), + jumpAction: { viewStore.send(.performJumpPage) } + ) + .searchable(text: viewStore.binding(\.$keyword), prompt: R.string.localizable.searchablePromptFilter()) + .navigationBarBackButtonHidden(viewStore.jumpPageAlertPresented) + .animation(.default, value: viewStore.jumpPageAlertPresented) + .onAppear { + if viewStore.galleries?.isEmpty != false { + DispatchQueue.main.async { + viewStore.send(.fetchGalleries()) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(navigationTitle) + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /ToplistsState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: ToplistsAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + } + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(disabled: viewStore.jumpPageAlertPresented) { + ToplistsTypeMenu(type: viewStore.type) { type in + if type != viewStore.type { + viewStore.send(.setToplistsType(type)) + } + } + JumpPageButton(pageNumber: viewStore.pageNumber ?? .init(), hideText: true) { + viewStore.send(.presentJumpPageAlert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + viewStore.send(.setJumpPageAlertFocused(true)) + } + } + } + } +} + +// MARK: Definition +enum ToplistsType: Int, Codable, CaseIterable, Identifiable { + var id: Int { rawValue } + + case yesterday + case pastMonth + case pastYear + case allTime +} + +extension ToplistsType { + var value: String { + switch self { + case .yesterday: + return R.string.localizable.enumToplistsTypeValueYesterday() + case .pastMonth: + return R.string.localizable.enumToplistsTypeValuePastMonth() + case .pastYear: + return R.string.localizable.enumToplistsTypeValuePastYear() + case .allTime: + return R.string.localizable.enumToplistsTypeValueAllTime() + } + } + var categoryIndex: Int { + switch self { + case .yesterday: + return 15 + case .pastMonth: + return 13 + case .pastYear: + return 12 + case .allTime: + return 11 + } + } +} + +struct ToplistsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ToplistsView( + store: .init( + initialState: .init(), + reducer: toplistsReducer, + environment: ToplistsEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } + } +} diff --git a/EhPanda/View/Home/WatchedView.swift b/EhPanda/View/Home/WatchedView.swift new file mode 100644 index 00000000..155bdd52 --- /dev/null +++ b/EhPanda/View/Home/WatchedView.swift @@ -0,0 +1,163 @@ +// +// WatchedView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import SwiftUI +import ComposableArchitecture + +struct WatchedView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + ZStack { + if CookiesUtil.didLogin { + GenericList( + galleries: viewStore.galleries, + setting: setting, + pageNumber: viewStore.pageNumber, + loadingState: viewStore.loadingState, + footerLoadingState: viewStore.footerLoadingState, + fetchAction: { viewStore.send(.fetchGalleries()) }, + fetchMoreAction: { viewStore.send(.fetchMoreGalleries) }, + navigateAction: { viewStore.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: !setting.translatesTags) + } + ) + } else { + NotLoginView(action: { viewStore.send(.onNotLoginViewButtonTapped) }) + } + } + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /WatchedState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: WatchedAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /WatchedState.Route.quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: WatchedAction.quickSearch) + ) { keyword in + viewStore.send(.setNavigation(nil)) + viewStore.send(.fetchGalleries(nil, keyword)) + } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /WatchedState.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: WatchedAction.filters)) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .jumpPageAlert( + index: viewStore.binding(\.$jumpPageIndex), + isPresented: viewStore.binding(\.$jumpPageAlertPresented), + isFocused: viewStore.binding(\.$jumpPageAlertFocused), + pageNumber: viewStore.pageNumber, + jumpAction: { viewStore.send(.performJumpPage) } + ) + .animation(.default, value: viewStore.jumpPageAlertPresented) + .navigationBarBackButtonHidden(viewStore.jumpPageAlertPresented) + .searchable(text: viewStore.binding(\.$keyword)) + .onSubmit(of: .search) { + viewStore.send(.fetchGalleries()) + } + .onAppear { + if viewStore.galleries.isEmpty && CookiesUtil.didLogin { + DispatchQueue.main.async { + viewStore.send(.fetchGalleries()) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.watchedViewTitleWatched()) + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /WatchedState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: WatchedAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + } + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(disabled: viewStore.jumpPageAlertPresented) { + ToolbarFeaturesMenu { + FiltersButton { + viewStore.send(.setNavigation(.filters)) + } + QuickSearchButton { + viewStore.send(.setNavigation(.quickSearch)) + } + JumpPageButton(pageNumber: viewStore.pageNumber) { + viewStore.send(.presentJumpPageAlert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + viewStore.send(.setJumpPageAlertFocused(true)) + } + } + } + } + } +} + +struct WatchedView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + WatchedView( + store: .init( + initialState: .init(), + reducer: watchedReducer, + environment: WatchedEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } + } +} diff --git a/EhPanda/View/Migration/MigrationStore.swift b/EhPanda/View/Migration/MigrationStore.swift new file mode 100644 index 00000000..b3a1f944 --- /dev/null +++ b/EhPanda/View/Migration/MigrationStore.swift @@ -0,0 +1,76 @@ +// +// MigrationStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/03. +// + +import ComposableArchitecture + +struct MigrationState: Equatable { + enum Route: Equatable { + case dropDialog + } + + @BindableState var route: Route? + var databaseState: LoadingState = .loading +} + +enum MigrationAction: BindableAction { + case binding(BindingAction) + case setNavigation(MigrationState.Route?) + case onDatabasePreparationSuccess + + case prepareDatabase + case prepareDatabaseDone(Result) + case dropDatabase + case dropDatabaseDone(Result) +} + +struct MigrationEnvironment { + let databaseClient: DatabaseClient +} + +let migrationReducer = Reducer { state, action, environment in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .onDatabasePreparationSuccess: + return .none + + case .prepareDatabase: + return environment.databaseClient.prepareDatabase().map(MigrationAction.prepareDatabaseDone) + + case .prepareDatabaseDone(let result): + switch result { + case .success: + state.databaseState = .idle + return .init(value: .onDatabasePreparationSuccess) + case .failure(let error): + state.databaseState = .failed(error) + return .none + } + + case .dropDatabase: + state.databaseState = .loading + return environment.databaseClient.dropDatabase() + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main) + .eraseToEffect().map(MigrationAction.dropDatabaseDone) + + case .dropDatabaseDone(let result): + switch result { + case .success: + state.databaseState = .idle + return .init(value: .onDatabasePreparationSuccess) + case .failure(let error): + state.databaseState = .failed(error) + return .none + } + } +} +.binding() diff --git a/EhPanda/View/Migration/MigrationView.swift b/EhPanda/View/Migration/MigrationView.swift new file mode 100644 index 00000000..88c13241 --- /dev/null +++ b/EhPanda/View/Migration/MigrationView.swift @@ -0,0 +1,66 @@ +// +// MigrationView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/03. +// + +import SwiftUI +import ComposableArchitecture + +struct MigrationView: View { + @Environment(\.colorScheme) private var colorScheme + private let store: Store + @ObservedObject private var viewStore: ViewStore + + private var reversedPrimary: Color { + colorScheme == .light ? .white : .black + } + + init(store: Store) { + self.store = store + viewStore = ViewStore(store) + } + + var body: some View { + NavigationView { + ZStack { + reversedPrimary.ignoresSafeArea() + LoadingView(title: R.string.localizable.loadingViewTitlePreparingDatabase()) + .opacity(viewStore.databaseState == .loading ? 1 : 0) + let error = (/LoadingState.failed).extract(from: viewStore.databaseState) + let errorNonNil = error ?? .databaseCorrupted(nil) + AlertView(symbol: errorNonNil.symbol, message: errorNonNil.localizedDescription) { + AlertViewButton(title: R.string.localizable.errorViewButtonDropDatabase()) { + viewStore.send(.setNavigation(.dropDialog)) + } + .confirmationDialog( + message: R.string.localizable.confirmationDialogTitleDropDatabase(), + unwrapping: viewStore.binding(\.$route), + case: /MigrationState.Route.dropDialog + ) { + Button(R.string.localizable.confirmationDialogButtonDropDatabase(), role: .destructive) { + viewStore.send(.dropDatabase) + } + } + } + .opacity(error != nil ? 1 : 0) + } + .animation(.default, value: viewStore.databaseState) + } + } +} + +struct MigrationView_Previews: PreviewProvider { + static var previews: some View { + MigrationView( + store: .init( + initialState: .init(), + reducer: migrationReducer, + environment: MigrationEnvironment( + databaseClient: .live + ) + ) + ) + } +} diff --git a/EhPanda/View/Reading/ControlPanel.swift b/EhPanda/View/Reading/ControlPanel.swift deleted file mode 100644 index 983ff5d7..00000000 --- a/EhPanda/View/Reading/ControlPanel.swift +++ /dev/null @@ -1,329 +0,0 @@ -// -// ControlPanel.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/07/30. -// - -import SwiftUI -import Kingfisher - -// MARK: ControlPanel -struct ControlPanel: View { - @State private var refreshTrigger = UUID().uuidString - - @Binding private var showsPanel: Bool - @Binding private var sliderValue: Float - @Binding private var setting: Setting - @Binding private var autoPlayPolicy: AutoPlayPolicy - private let currentIndex: Int - private let range: ClosedRange - private let previews: [Int: String] - private let settingAction: () -> Void - private let fetchAction: (Int) -> Void - private let sliderChangedAction: (Int) -> Void - private let updateSettingAction: (Setting) -> Void - - init( - showsPanel: Binding, sliderValue: Binding, - setting: Binding, autoPlayPolicy: Binding, - currentIndex: Int, range: ClosedRange, previews: [Int: String], - settingAction: @escaping () -> Void, fetchAction: @escaping (Int) -> Void, - sliderChangedAction: @escaping (Int) -> Void, - updateSettingAction: @escaping (Setting) -> Void - ) { - _showsPanel = showsPanel - _sliderValue = sliderValue - _setting = setting - _autoPlayPolicy = autoPlayPolicy - self.currentIndex = currentIndex - self.range = range - self.previews = previews - self.settingAction = settingAction - self.fetchAction = fetchAction - self.sliderChangedAction = sliderChangedAction - self.updateSettingAction = updateSettingAction - } - - var body: some View { - VStack { - UpperPanel( - title: "\(currentIndex) / " + "\(Int(range.upperBound))", - setting: $setting, refreshTrigger: $refreshTrigger, - autoPlayPolicy: $autoPlayPolicy, - settingAction: settingAction, - updateSettingAction: updateSettingAction - ) - .offset(y: showsPanel ? 0 : -50) - Spacer() - if range.upperBound > range.lowerBound { - LowerPanel( - sliderValue: $sliderValue, previews: previews, range: range, - isReversed: setting.readingDirection == .rightToLeft, - fetchAction: fetchAction, sliderChangedAction: sliderChangedAction - ) - .offset(y: showsPanel ? 0 : 50) - } - } - .opacity(showsPanel ? 1 : 0).disabled(!showsPanel) - .onChange(of: showsPanel) { newValue in - guard newValue else { return } // workaround - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - refreshTrigger = UUID().uuidString - } - } - } -} - -// MARK: UpperPanel -private struct UpperPanel: View { - @Environment(\.dismiss) var dismissAction - @Binding private var setting: Setting - @Binding private var refreshTrigger: String - @Binding private var autoPlayPolicy: AutoPlayPolicy - - private let title: String - private let settingAction: () -> Void - private let updateSettingAction: (Setting) -> Void - - init( - title: String, setting: Binding, - refreshTrigger: Binding, autoPlayPolicy: Binding, - settingAction: @escaping () -> Void, updateSettingAction: @escaping (Setting) -> Void - ) { - self.title = title - _setting = setting - _refreshTrigger = refreshTrigger - _autoPlayPolicy = autoPlayPolicy - self.settingAction = settingAction - self.updateSettingAction = updateSettingAction - } - - var body: some View { - ZStack { - HStack { - Button(action: dismissAction.callAsFunction) { - Image(systemName: "chevron.backward") - } - .font(.title2).padding(.leading, 20) - Spacer() - Slider(value: .constant(0)).opacity(0) - Spacer() - HStack(spacing: 20) { - if DeviceUtil.isLandscape && setting.readingDirection != .vertical { - Menu { - Button { - var setting = setting - setting.enablesDualPageMode.toggle() - updateSettingAction(setting) - } label: { - Text("Dual-page mode") - if setting.enablesDualPageMode { - Image(systemName: "checkmark") - } - } - Button { - var setting = setting - setting.exceptCover.toggle() - updateSettingAction(setting) - } label: { - Text("Except the cover") - if setting.exceptCover { - Image(systemName: "checkmark") - } - } - .disabled(!setting.enablesDualPageMode) - } label: { - Image(systemName: "rectangle.split.2x1") - .symbolVariant(setting.enablesDualPageMode ? .fill : .none) - } - } - Image(systemName: "timer").opacity(0.01) - .overlay { - Menu { - Text("AutoPlay").foregroundColor(.secondary) - ForEach(AutoPlayPolicy.allCases) { policy in - Button { - autoPlayPolicy = policy - } label: { - Text(policy.descriptionKey) - if autoPlayPolicy == policy { - Image(systemName: "checkmark") - } - } - } - } label: { - Image(systemName: "timer") - } - } - .id(refreshTrigger) - Button(action: settingAction) { - Image(systemName: "gear") - } - .padding(.trailing, 20) - } - .font(.title2) - } - Text(title).bold().lineLimit(1).padding() - } - .background(.thinMaterial) - } -} - -// MARK: LowerPanel -private struct LowerPanel: View { - @State private var isSliderDragging = false - @Binding var sliderValue: Float - private let previews: [Int: String] - private let range: ClosedRange - private let isReversed: Bool - private let fetchAction: (Int) -> Void - private let sliderChangedAction: (Int) -> Void - - init( - sliderValue: Binding, previews: [Int: String], - range: ClosedRange, isReversed: Bool, - fetchAction: @escaping (Int) -> Void, - sliderChangedAction: @escaping (Int) -> Void - ) { - _sliderValue = sliderValue - self.previews = previews - self.range = range - self.isReversed = isReversed - self.fetchAction = fetchAction - self.sliderChangedAction = sliderChangedAction - } - - var body: some View { - VStack(spacing: 0) { - SliderPreivew( - isSliderDragging: $isSliderDragging, - sliderValue: $sliderValue, previews: previews, range: range, - isReversed: isReversed, fetchAction: fetchAction - ) - VStack { - HStack { - Text(lowerBoundText).boundTextModifier() - Slider( - value: $sliderValue, in: range, step: 1, - onEditingChanged: { isDragging in - sliderChangedAction(Int(sliderValue)) - HapticUtil.generateFeedback(style: .soft) - withAnimation { - isSliderDragging = isDragging - } - } - ) - .rotationEffect(sliderAngle) - Text(upperBoundText).boundTextModifier() - } - .padding(.horizontal).padding(.bottom) - } - } - .background(.thinMaterial) - } -} - -private extension LowerPanel { - var lowerBoundText: String { - isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))" - } - var upperBoundText: String { - isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))" - } - var sliderAngle: Angle { - Angle(degrees: isReversed ? 180 : 0) - } -} - -// MARK: SliderPreview -private struct SliderPreivew: View { - @Binding private var isSliderDragging: Bool - @Binding var sliderValue: Float - private let previews: [Int: String] - private let range: ClosedRange - private let isReversed: Bool - private let fetchAction: (Int) -> Void - - init( - isSliderDragging: Binding, sliderValue: Binding, - previews: [Int: String], range: ClosedRange, - isReversed: Bool, fetchAction: @escaping (Int) -> Void - ) { - _isSliderDragging = isSliderDragging - _sliderValue = sliderValue - self.previews = previews - self.range = range - self.isReversed = isReversed - self.fetchAction = fetchAction - } - - var body: some View { - HStack(spacing: previewSpacing) { - ForEach(previewsIndices, id: \.self) { index in - let (url, modifier) = - PreviewResolver.getPreviewConfigs( - originalURL: previews[index] ?? "" - ) - VStack { - KFImage.url(URL(string: url), cacheKey: previews[index]) - .placeholder { - Placeholder(style: .activity( - ratio: Defaults.ImageSize.previewAspect - )) - } - .fade(duration: 0.25) - .imageModifier(modifier).resizable().scaledToFit() - .frame(width: previewWidth, height: isSliderDragging ? previewHeight : 0) - Text("\(index)").font(DeviceUtil.isPadWidth ? .callout : .caption) - .foregroundColor(index == Int(sliderValue) ? .accentColor : .secondary) - } - .onAppear { - guard previews[index] == nil && checkIndex(index) else { return } - fetchAction(index) - } - .opacity(checkIndex(index) ? 1 : 0) - } - } - .opacity(isSliderDragging ? 1 : 0).padding(.vertical, verticalPadding) - .frame(height: isSliderDragging ? previewHeight + verticalPadding * 2 : 0) - } -} - -private extension SliderPreivew { - var verticalPadding: CGFloat { - DeviceUtil.isPadWidth ? 30 : 20 - } - var previewsCount: Int { - DeviceUtil.isPadWidth ? DeviceUtil.isLandscape ? 7 : 5 : 3 - } - var previewsIndices: [Int] { - guard !previews.isEmpty else { return [] } - let currentIndex = Int(sliderValue) - let distance = (previewsCount - 1) / 2 - let lowerBound = currentIndex - distance - let upperBound = currentIndex + distance - - let indices = Array(lowerBound...upperBound) - return isReversed ? indices.reversed() : indices - } - var previewSpacing: CGFloat { 10 } - var previewHeight: CGFloat { - previewWidth / Defaults.ImageSize.previewAspect - } - var previewWidth: CGFloat { - guard previewsCount > 0 else { return 0 } - let count = CGFloat(previewsCount) - let spacing = (count + 1) * previewSpacing - return (DeviceUtil.windowW - spacing) / count - } - func checkIndex(_ index: Int) -> Bool { - index >= Int(range.lowerBound) && index <= Int(range.upperBound) - } -} - -private extension Text { - func boundTextModifier() -> some View { - self.fontWeight(.medium).font(.caption).padding() - } -} diff --git a/EhPanda/View/Reading/ReadingStore.swift b/EhPanda/View/Reading/ReadingStore.swift new file mode 100644 index 00000000..df6e2468 --- /dev/null +++ b/EhPanda/View/Reading/ReadingStore.swift @@ -0,0 +1,729 @@ +// +// ReadingStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/22. +// + +import SwiftUI +import TTProgressHUD +import ComposableArchitecture + +struct ReadingState: Equatable { + enum Route: Equatable { + case hud + case share(UIImage) + case readingSetting + } + enum ImageAction { + case copy + case save + case share + } + struct CancelID: Hashable { + let id = String(describing: ReadingState.CancelID.self) + } + struct TimerID: Hashable { + let id = String(describing: ReadingState.TimerID.self) + } + + @BindableState var route: Route? + let gallery: Gallery + + var hudConfig: TTProgressHUDConfig = .loading + + var imageURLLoadingStates = [Int: LoadingState]() + var previewLoadingStates = [Int: LoadingState]() + var databaseLoadingState: LoadingState = .loading + var previewConfig: PreviewConfig = .normal(rows: 4) + + var previewURLs = [Int: URL]() + + var thumbnailURLs = [Int: URL]() + var imageURLs = [Int: URL]() + var originalImageURLs = [Int: URL]() + + var mpvKey: String? + var mpvImageKeys = [Int: String]() + var mpvReloadTokens = [Int: String]() + + @BindableState var pageIndex = 0 + @BindableState var showsPanel = false + @BindableState var sliderValue: Float = 1 + @BindableState var showsSliderPreview = false + @BindableState var autoPlayPolicy: AutoPlayPolicy = .off + + var scaleAnchor: UnitPoint = .center + var scale: Double = 1 + var baseScale: Double = 1 + var offset: CGSize = .zero + var newOffset: CGSize = .zero + + // Update + func update(stored: inout [Int: T], new: [Int: T], replaceExisting: Bool = true) { + guard !new.isEmpty else { return } + stored = stored.merging(new, uniquingKeysWith: { stored, new in replaceExisting ? new : stored }) + } + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { + update(stored: &self.previewURLs, new: previewURLs) + } + mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { + update(stored: &self.thumbnailURLs, new: thumbnailURLs) + } + mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { + update(stored: &self.imageURLs, new: imageURLs) + update(stored: &self.originalImageURLs, new: originalImageURLs) + } + + // Page + func mapFromPager(index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { + guard isLandscape && setting.enablesDualPageMode + && setting.readingDirection != .vertical + else { return index + 1 } + guard index > 0 else { return 1 } + + let result = setting.exceptCover ? index * 2 : index * 2 + 1 + + if result + 1 == gallery.pageCount { + return gallery.pageCount + } else { + return result + } + } + func mapToPager(index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { + guard isLandscape && setting.enablesDualPageMode + && setting.readingDirection != .vertical + else { return index - 1 } + guard index > 1 else { return 0 } + + return setting.exceptCover ? index / 2 : (index - 1) / 2 + } + + // Image + func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { + let defaultData = Array(1...gallery.pageCount) + guard isLandscape && setting.enablesDualPageMode + && setting.readingDirection != .vertical + else { return defaultData } + + let data = setting.exceptCover + ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) + : Array(stride(from: 1, through: gallery.pageCount, by: 2)) + + return data + } + func imageContainerConfigs( + index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape + ) -> ImageStackConfig { + let direction = setting.readingDirection + let isReversed = direction == .rightToLeft + let isFirstSingle = setting.exceptCover + let isFirstPageAndSingle = index == 1 && isFirstSingle + let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index + let secondIndex = firstIndex + (isReversed ? -1 : 1) + let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount + let isValidSecondRange = isFirstSingle + ? secondIndex >= 2 && secondIndex <= gallery.pageCount + : secondIndex >= 1 && secondIndex <= gallery.pageCount + return .init( + firstIndex: firstIndex, secondIndex: secondIndex, isFirstAvailable: isValidFirstRange, + isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage + ) + } + + // Gesture + func edgeWidth(x: Double, absWindowW: Double) -> Double { + let marginW = 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) + } + func edgeHeight(y: Double, absWindowH: Double) -> Double { + let marginH = 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) + } + mutating func correctOffset(absWindowW: Double, absWindowH: Double) { + offset.width = edgeWidth(x: offset.width, absWindowW: absWindowW) + offset.height = edgeHeight(y: offset.height, absWindowH: absWindowH) + } + mutating func correctScaleAnchor(point: CGPoint, absWindowW: Double, absWindowH: Double) { + let x = min(1, max(0, point.x / absWindowW)) + let y = min(1, max(0, point.y / absWindowH)) + scaleAnchor = .init(x: x, y: y) + } + mutating func setOffset(_ offset: CGSize, absWindowW: Double, absWindowH: Double) { + self.offset = offset + correctOffset(absWindowW: absWindowW, absWindowH: absWindowH) + } + mutating func setScale(scale: Double, maximum: Double, absWindowW: Double, absWindowH: Double) { + guard scale >= 1 && scale <= maximum else { return } + self.scale = scale + correctOffset(absWindowW: absWindowW, absWindowH: absWindowH) + } + mutating func onDragGestureChanged(_ value: DragGesture.Value, absWindowW: Double, absWindowH: Double) { + guard scale > 1 else { return } + let newX = value.translation.width + newOffset.width + let newY = value.translation.height + newOffset.height + let newOffsetW = edgeWidth(x: newX, absWindowW: absWindowW) + let newOffsetH = edgeHeight(y: newY, absWindowH: absWindowH) + setOffset(.init(width: newOffsetW, height: newOffsetH), absWindowW: absWindowW, absWindowH: absWindowH) + } + mutating func onMagnificationGestureChanged( + _ value: Double, point: CGPoint?, scaleMaximum: Double, + absWindowW: Double, absWindowH: Double + ) { + if value == 1 { + baseScale = scale + } + if let point = point { + correctScaleAnchor(point: point, absWindowW: absWindowW, absWindowH: absWindowH) + } + setScale(scale: value * baseScale, maximum: scaleMaximum, absWindowW: absWindowW, absWindowH: absWindowH) + } +} + +enum ReadingAction: BindableAction { + case binding(BindingAction) + case setNavigation(ReadingState.Route?) + + case toggleShowsPanel + case setPageIndex(Int) + case setSliderValue(Float) + case setOrientationPortrait(Bool) + case onTimerFired + case onAppear(Bool) + + case onWebImageRetry(Int) + case onWebImageSucceeded(Int) + case onWebImageFailed(Int) + + case copyImage(URL) + case saveImage(URL) + case saveImageDone(Bool) + case shareImage(URL) + case fetchImage(ReadingState.ImageAction, URL) + case fetchImageDone(ReadingState.ImageAction, Result) + + case onSingleTapGestureEnded(ReadingDirection) + case onDoubleTapGestureEnded(Double, Double) + case onMagnificationGestureChanged(Double, Double) + case onMagnificationGestureEnded(Double, Double) + case onDragGestureChanged(DragGesture.Value) + case onDragGestureEnded(DragGesture.Value) + + case syncReadingProgress + case syncPreviewURLs([Int: URL]) + case syncThumbnailURLs([Int: URL]) + case syncImageURLs([Int: URL], [Int: URL]) + + case teardown + case fetchDatabaseInfos + case fetchDatabaseInfosDone(GalleryState) + + case fetchPreviewURLs(Int) + case fetchPreviewURLsDone(Int, Result<[Int: URL], AppError>) + + case fetchImageURLs(Int) + case refetchImageURLs(Int) + case prefetchImages(Int, Int) + + case fetchThumbnailURLs(Int) + case fetchThumbnailURLsDone(Int, Result<[Int: URL], AppError>) + case fetchNormalImageURLs(Int, [Int: URL]) + case fetchNormalImageURLsDone(Int, Result<([Int: URL], [Int: URL]), AppError>) + case refetchNormalImageURLs(Int) + case refetchNormalImageURLsDone(Int, Result<([Int: URL], HTTPURLResponse?), AppError>) + + case fetchMPVKeys(Int, URL) + case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) + case fetchMPVImageURL(Int, Bool) + case fetchMPVImageURLDone(Int, Result<(URL, URL?, String), AppError>) +} + +struct ReadingEnvironment { + let urlClient: URLClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient +} + +let readingReducer = Reducer { state, action, environment in + switch action { + case .binding(\.$showsSliderPreview): + return environment.hapticClient.generateFeedback(.soft).fireAndForget() + + case .binding(\.$autoPlayPolicy): + var effects: [Effect] = [ + .cancel(id: ReadingState.TimerID()) + ] + let timeInterval = TimeInterval(state.autoPlayPolicy.rawValue) + if timeInterval > 0 { + effects.append( + Effect + .timer( + id: ReadingState.TimerID(), + every: .init(timeInterval), + on: AnySchedulerOf.main + ) + .map({ _ in ReadingAction.onTimerFired }) + ) + } + return .merge(effects) + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .toggleShowsPanel: + state.showsPanel.toggle() + return .none + + case .setPageIndex(let index): + state.pageIndex = index + return .none + + case .setSliderValue(let value): + state.sliderValue = value + return .none + + case .setOrientationPortrait(let isPortrait): + var effects = [Effect]() + if isPortrait { + effects.append(environment.appDelegateClient.setPortraitOrientationMask().fireAndForget()) + effects.append(environment.appDelegateClient.setPortraitOrientation().fireAndForget()) + } else { + effects.append(environment.appDelegateClient.setAllOrientationMask().fireAndForget()) + } + return .merge(effects) + + case .onTimerFired: + state.pageIndex += 1 + return .none + + case .onAppear(let enablesLandscape): + var effects: [Effect] = [ + .init(value: .fetchDatabaseInfos) + ] + if enablesLandscape { + effects.append(.init(value: .setOrientationPortrait(false))) + } + return .merge(effects) + + case .onWebImageRetry(let index): + state.imageURLLoadingStates[index] = .idle + return .none + + case .onWebImageSucceeded(let index): + state.imageURLLoadingStates[index] = .idle + return .none + + case .onWebImageFailed(let index): + state.imageURLLoadingStates[index] = .failed(.webImageFailed) + return .none + + case .copyImage(let imageURL): + return .init(value: .fetchImage(.copy, imageURL)) + + case .saveImage(let imageURL): + return .init(value: .fetchImage(.save, imageURL)) + + case .saveImageDone(let isSucceeded): + state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error + return .init(value: .setNavigation(.hud)) + + case .shareImage(let imageURL): + return .init(value: .fetchImage(.share, imageURL)) + + case .fetchImage(let action, let imageURL): + return environment.imageClient.fetchImage(url: imageURL) + .map({ ReadingAction.fetchImageDone(action, $0) }) + .cancellable(id: ReadingState.CancelID()) + + case .fetchImageDone(let action, let result): + if case .success(let image) = result { + switch action { + case .copy: + state.hudConfig = .copiedToClipboardSucceeded + return .merge( + .init(value: .setNavigation(.hud)), + environment.clipboardClient.saveImage(image).fireAndForget() + ) + case .save: + return environment.imageClient + .saveImageToPhotoLibrary(image).map(ReadingAction.saveImageDone) + case .share: + return .init(value: .setNavigation(.share(image))) + } + } else { + state.hudConfig = .error + return .init(value: .setNavigation(.hud)) + } + + case .onSingleTapGestureEnded(let readingDirection): + guard readingDirection != .vertical, + let pointX = environment.deviceClient.touchPoint()?.x + else { return .init(value: .toggleShowsPanel) } + let rightToLeft = readingDirection == .rightToLeft + if pointX < environment.deviceClient.absWindowW() * 0.2 { + state.pageIndex += rightToLeft ? 1 : -1 + } else if pointX > environment.deviceClient.absWindowW() * (1 - 0.2) { + state.pageIndex += rightToLeft ? -1 : 1 + } else { + return .init(value: .toggleShowsPanel) + } + return .none + + case .onDoubleTapGestureEnded(let scaleMaximum, let doubleTapScale): + let newScale = state.scale == 1 ? doubleTapScale : 1 + let absWindowW = environment.deviceClient.absWindowW() + let absWindowH = environment.deviceClient.absWindowH() + if let point = environment.deviceClient.touchPoint() { + state.correctScaleAnchor(point: point, absWindowW: absWindowW, absWindowH: absWindowH) + } + state.setOffset(.zero, absWindowW: absWindowW, absWindowH: absWindowH) + state.setScale(scale: newScale, maximum: scaleMaximum, absWindowW: absWindowW, absWindowH: absWindowH) + return .none + + case .onMagnificationGestureChanged(let value, let scaleMaximum): + let point = environment.deviceClient.touchPoint() + let absWindowW = environment.deviceClient.absWindowW() + let absWindowH = environment.deviceClient.absWindowH() + state.onMagnificationGestureChanged( + value, point: point, scaleMaximum: scaleMaximum, + absWindowW: absWindowW, absWindowH: absWindowH + ) + return .none + + case .onMagnificationGestureEnded(let value, let scaleMaximum): + let point = environment.deviceClient.touchPoint() + let absWindowW = environment.deviceClient.absWindowW() + let absWindowH = environment.deviceClient.absWindowH() + state.onMagnificationGestureChanged( + value, point: point, scaleMaximum: scaleMaximum, + absWindowW: absWindowW, absWindowH: absWindowH + ) + if value * state.baseScale - 1 < 0.01 { + state.setScale( + scale: 1, maximum: scaleMaximum, + absWindowW: absWindowW, absWindowH: absWindowH + ) + } + state.baseScale = state.scale + return .none + + case .onDragGestureChanged(let value): + let absWindowW = environment.deviceClient.absWindowW() + let absWindowH = environment.deviceClient.absWindowH() + state.onDragGestureChanged(value, absWindowW: absWindowW, absWindowH: absWindowH) + return .none + + case .onDragGestureEnded(let value): + let absWindowW = environment.deviceClient.absWindowW() + let absWindowH = environment.deviceClient.absWindowH() + state.onDragGestureChanged(value, absWindowW: absWindowW, absWindowH: absWindowH) + if state.scale > 1 { + state.newOffset.width = state.offset.width + state.newOffset.height = state.offset.height + } + return .none + + case .syncReadingProgress: + return environment.databaseClient + .updateReadingProgress(gid: state.gallery.id, progress: .init(state.sliderValue)).fireAndForget() + + case .syncPreviewURLs(let previewURLs): + return environment.databaseClient + .updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs).fireAndForget() + + case .syncThumbnailURLs(let thumbnailURLs): + return environment.databaseClient + .updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs).fireAndForget() + + case .syncImageURLs(let imageURLs, let originalImageURLs): + return environment.databaseClient + .updateImageURLs(gid: state.gallery.id, imageURLs: imageURLs, originalImageURLs: originalImageURLs) + .fireAndForget() + + case .teardown: + var effects: [Effect] = [ + .cancel(id: ReadingState.CancelID()), + .cancel(id: ReadingState.TimerID()) + ] + if !environment.deviceClient.isPad() { + effects.append(.init(value: .setOrientationPortrait(true))) + } + return .merge(effects) + + case .fetchDatabaseInfos: + return environment.databaseClient.fetchGalleryState(gid: state.gallery.id) + .map(ReadingAction.fetchDatabaseInfosDone).cancellable(id: ReadingState.CancelID()) + + 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.databaseLoadingState = .idle + return .init(value: .setSliderValue(.init(galleryState.readingProgress))) + + 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 GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum) + .effect.map({ ReadingAction.fetchPreviewURLsDone(index, $0) }).cancellable(id: ReadingState.CancelID()) + + 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 .init(value: .syncPreviewURLs(previewURLs)) + case .failure(let error): + state.previewLoadingStates[index] = .failed(error) + } + return .none + + case .fetchImageURLs(let index): + if state.mpvKey != nil { + return .init(value: .fetchMPVImageURL(index, false)) + } else { + return .init(value: .fetchThumbnailURLs(index)) + } + + case .refetchImageURLs(let index): + if state.mpvKey != nil { + return .init(value: .fetchMPVImageURL(index, true)) + } else { + return .init(value: .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(.init(value: .fetchImageURLs($0))) + } + effects.append(environment.imageClient.prefetchImages(prefetchImageURLs).fireAndForget()) + 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 ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum) + .effect.map({ ReadingAction.fetchThumbnailURLsDone(index, $0) }).cancellable(id: ReadingState.CancelID()) + + 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], environment.urlClient.checkIfMPVURL(url) { + return .init(value: .fetchMPVKeys(index, url)) + } else { + state.updateThumbnailURLs(thumbnailURLs) + return .merge( + .init(value: .syncThumbnailURLs(thumbnailURLs)), + .init(value: .fetchNormalImageURLs(index, thumbnailURLs)) + ) + } + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + + case .fetchNormalImageURLs(let index, let thumbnailURLs): + return GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs) + .effect.map({ ReadingAction.fetchNormalImageURLsDone(index, $0) }).cancellable(id: ReadingState.CancelID()) + + 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 .init(value: .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 GalleryNormalImageURLRefetchRequest( + index: index, pageNum: pageNum, + galleryURL: galleryURL, + thumbnailURL: state.thumbnailURLs[index], + storedImageURL: imageURL + ) + .effect.map({ ReadingAction.refetchNormalImageURLsDone(index, $0) }).cancellable(id: ReadingState.CancelID()) + + case .refetchNormalImageURLsDone(let index, let result): + switch result { + case .success(let (imageURLs, response)): + var effects = [Effect]() + if let response = response { + effects.append(environment.cookiesClient.setSkipServer(response: response).fireAndForget()) + } + guard !imageURLs.isEmpty else { + state.imageURLLoadingStates[index] = .failed(.notFound) + return effects.isEmpty ? .none : .merge(effects) + } + state.imageURLLoadingStates[index] = .idle + state.updateImageURLs(imageURLs, [:]) + effects.append(.init(value: .syncImageURLs(imageURLs, [:]))) + return .merge(effects) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none + + case .fetchMPVKeys(let index, let mpvURL): + return MPVKeysRequest(mpvURL: mpvURL) + .effect.map({ ReadingAction.fetchMPVKeysDone(index, $0) }).cancellable(id: ReadingState.CancelID()) + + 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 { + .init(value: .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 reloadToken = isRefresh ? state.mpvReloadTokens[index] : nil + return GalleryMPVImageURLRequest( + gid: gidInteger, index: index, mpvKey: mpvKey, + mpvImageKey: mpvImageKey, reloadToken: reloadToken + ) + .effect.map({ ReadingAction.fetchMPVImageURLDone(index, $0) }).cancellable(id: ReadingState.CancelID()) + + case .fetchMPVImageURLDone(let index, let result): + switch result { + case .success(let (imageURL, originalImageURL, reloadToken)): + let imageURLs: [Int: URL] = [index: imageURL] + var originalImageURLs = [Int: URL]() + if let originalImageURL = originalImageURL { + originalImageURLs[index] = originalImageURL + } + state.imageURLLoadingStates[index] = .idle + state.mpvReloadTokens[index] = reloadToken + state.updateImageURLs(imageURLs, originalImageURLs) + return .init(value: .syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none + } +} +.haptics( + unwrapping: \.route, + case: /ReadingState.Route.readingSetting, + hapticClient: \.hapticClient +) +.haptics( + unwrapping: \.route, + case: /ReadingState.Route.share, + hapticClient: \.hapticClient +) +.binding() diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 61bf3513..6b5996e7 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -2,638 +2,339 @@ // ReadingView.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/13. +// Created by 荒木辰造 on R 4/01/22. // import SwiftUI -import Combine import Kingfisher import SwiftUIPager -import TTProgressHUD - -struct ReadingView: View, StoreAccessor, PersistenceAccessor { - @EnvironmentObject var store: Store +import ComposableArchitecture +struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme - private var backgroundColor: Color { - colorScheme == .light - ? Color(.systemGray4) - : Color(.systemGray6) - } - - @StateObject private var page: Page = .first() - - @State private var showsPanel = false - @State private var sliderValue: Float = 1 - @State private var sheetState: ReadingViewSheetState? - @State private var autoPlayTimer: Timer? - @State private var autoPlayPolicy: AutoPlayPolicy = .never + let store: Store + @ObservedObject private var viewStore: ViewStore + @Binding private var setting: Setting + private let blurRadius: Double + private let dismissAction: () -> Void - @State private var scaleAnchor: UnitPoint = .center - @State private var scale: CGFloat = 1 - @State private var baseScale: CGFloat = 1 - @State private var offset: CGSize = .zero - @State private var newOffset: CGSize = .zero - - @State private var pageCount = 1 - - @StateObject private var imageSaver = ImageSaver() - @State private var hudVisible = false - @State private var hudConfig = TTProgressHUDConfig() - - let gid: String + @StateObject private var page: Page = .first() - init(gid: String) { - self.gid = gid - initializeParams() + init( + store: Store, + setting: Binding, blurRadius: Double, + dismissAction: @escaping () -> Void + ) { + self.store = store + viewStore = ViewStore(store) + _setting = setting + self.blurRadius = blurRadius + self.dismissAction = dismissAction } - mutating func initializeParams() { - AppUtil.dispatchMainSync { - _pageCount = State( - initialValue: galleryDetail?.pageCount ?? 1 - ) - } + private var backgroundColor: Color { + colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) } - // MARK: ReadingView var body: some View { ZStack { backgroundColor.ignoresSafeArea() - conditionalList - .scaleEffect(scale, anchor: scaleAnchor) - .offset(offset).transition(AppUtil.opacityTransition) - .gesture(tapGesture).gesture(dragGesture) - .gesture(magnifyGesture).ignoresSafeArea() + ZStack { + if setting.readingDirection == .vertical { + AdvancedList( + page: page, data: viewStore.state.containerDataSource(setting: setting), + id: \.self, spacing: setting.contentDividerHeight, + gesture: SimultaneousGesture(magnificationGesture, tapGesture), + content: imageStack + ) + .disabled(viewStore.scale != 1) + } else { + Pager( + page: page, data: viewStore.state.containerDataSource(setting: setting), + id: \.self, content: imageStack + ) + .horizontal(setting.readingDirection == .rightToLeft ? .rightToLeft : .leftToRight) + .swipeInteractionArea(.allAvailable).allowsDragging(viewStore.scale == 1) + } + } + .scaleEffect(viewStore.scale, anchor: viewStore.scaleAnchor) + .offset(viewStore.offset).gesture(tapGesture).gesture(dragGesture) + .gesture(magnificationGesture).ignoresSafeArea() + .id(viewStore.databaseLoadingState) ControlPanel( - showsPanel: $showsPanel, - sliderValue: $sliderValue, - setting: $store.appState.settings.setting, - autoPlayPolicy: $autoPlayPolicy, - currentIndex: mapFromPager(index: page.index), - range: 1...Float(pageCount), - previews: detailInfo.previews[gid] ?? [:], - settingAction: presentSettingSheet, - fetchAction: fetchGalleryPreivews, - sliderChangedAction: tryUpdatePagerIndex, - updateSettingAction: updateSetting + showsPanel: viewStore.binding(\.$showsPanel), + showsSliderPreview: viewStore.binding(\.$showsSliderPreview), + sliderValue: viewStore.binding(\.$sliderValue), + setting: $setting, + autoPlayPolicy: viewStore.binding(\.$autoPlayPolicy), + range: 1...Float(viewStore.gallery.pageCount), + previewURLs: viewStore.previewURLs, dismissAction: dismissAction, + navigateSettingAction: { viewStore.send(.setNavigation(.readingSetting)) }, + fetchPreviewURLsAction: { viewStore.send(.fetchPreviewURLs($0)) } ) - TTProgressHUD($hudVisible, config: hudConfig) - } - .statusBar(hidden: !showsPanel) - .onAppear(perform: onStartTasks) - .onDisappear(perform: onEndTasks) - .navigationBarBackButtonHidden(true) - .navigationBarHidden(environment.navigationBarHidden) - .sheet(item: $sheetState, content: sheet) - .onChange(of: page.index, perform: updateSliderValue) - .onChange(of: autoPlayPolicy, perform: reconfigureTimer) - .onChange(of: setting.exceptCover, perform: tryUpdatePagerIndex) - .onChange(of: setting.readingDirection, perform: tryUpdatePagerIndex) - .onChange(of: setting.enablesDualPageMode, perform: tryUpdatePagerIndex) - .onChange(of: imageSaver.saveSucceeded, perform: { newValue in - guard let isSuccess = newValue else { return } - presentHUD(isSuccess: isSuccess, caption: "Saved to photo library") - }) - .onReceive(AppNotification.appWidthDidChange.publisher) { _ in - DispatchQueue.main.async { - trySetOffset(.zero) - trySetScale(1.1) - trySetScale(1) - } - tryUpdatePagerIndex() } - .onReceive(UIApplication.didBecomeActiveNotification.publisher) { _ in - trySetOrientation(allowsLandscape: true, shouldChangeOrientation: true) - } - .onReceive(UIApplication.willTerminateNotification.publisher) { _ in onEndTasks() } - .onReceive(UIApplication.willResignActiveNotification.publisher) { _ in onEndTasks() } - .onReceive(AppNotification.readingViewShouldHideStatusBar.publisher, perform: trySetNavigationBarHidden) - } - // MARK: ImageContainer - private var containerDataSource: [Int] { - let defaultData = Array(1...pageCount) - guard DeviceUtil.isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return defaultData } - - let data = setting.exceptCover - ? [1] + Array(stride(from: 2, through: pageCount, by: 2)) - : Array(stride(from: 1, through: pageCount, by: 2)) - - return data - } - private func getImageContainerConfigs(index: Int) -> (Int, Int, Bool, Bool) { - let direction = setting.readingDirection - let isReversed = direction == .rightToLeft - let isFirstSingle = setting.exceptCover - let isFirstPageAndSingle = index == 1 && isFirstSingle - let isDualPage = DeviceUtil.isLandscape - && setting.enablesDualPageMode - && direction != .vertical - - let firstIndex = isDualPage && isReversed && - !isFirstPageAndSingle ? index + 1 : index - let secondIndex = firstIndex + (isReversed ? -1 : 1) - let isValidFirstRange = - firstIndex >= 1 && firstIndex <= pageCount - let isValidSecondRange = isFirstSingle - ? secondIndex >= 2 && secondIndex <= pageCount - : secondIndex >= 1 && secondIndex <= pageCount - return ( - firstIndex, secondIndex, isValidFirstRange, - !isFirstPageAndSingle && isValidSecondRange && isDualPage - ) - } - private func imageContainer(index: Int) -> some View { - HStack(spacing: 0) { - let (firstIndex, secondIndex, isFirstValid, isSecondValid) = - getImageContainerConfigs(index: index) - let isDualPage = setting.enablesDualPageMode - && setting.readingDirection != .vertical - && DeviceUtil.isLandscape - - if isFirstValid { - ImageContainer( - index: firstIndex, - imageURL: galleryContents[firstIndex] ?? "", - loadingFlag: galleryLoadingFlags[firstIndex] ?? false, - loadError: galleryLoadErrors[firstIndex], - isDualPage: isDualPage, - retryAction: tryFetchGalleryContents, - reloadAction: refetchGalleryContents - ) - .onAppear { tryFetchGalleryContents(index: firstIndex) } - .contextMenu { contextMenuItems(index: firstIndex) } - } - - if isSecondValid { - ImageContainer( - index: secondIndex, - imageURL: galleryContents[secondIndex] ?? "", - loadingFlag: galleryLoadingFlags[secondIndex] ?? false, - loadError: galleryLoadErrors[secondIndex], - isDualPage: isDualPage, - retryAction: tryFetchGalleryContents, - reloadAction: refetchGalleryContents + .sheet(unwrapping: viewStore.binding(\.$route), case: /ReadingState.Route.readingSetting) { _ in + NavigationView { + ReadingSettingView( + readingDirection: $setting.readingDirection, + prefetchLimit: $setting.prefetchLimit, + enablesLandscape: $setting.enablesLandscape, + contentDividerHeight: $setting.contentDividerHeight, + maximumScaleFactor: $setting.maximumScaleFactor, + doubleTapScaleFactor: $setting.doubleTapScaleFactor ) - .onAppear { tryFetchGalleryContents(index: secondIndex) } - .contextMenu { contextMenuItems(index: secondIndex) } - } - } - } - // MARK: ContextMenu - @ViewBuilder private func contextMenuItems(index: Int) -> some View { - Button(action: { refetchGalleryContents(index: index) }, label: { - Label("Reload", systemImage: "arrow.counterclockwise") - }) - if let imageURL = galleryContents[index], !imageURL.isEmpty { - Button(action: { Task { await copyImage(url: imageURL) } }, label: { - Label("Copy", systemImage: "plus.square.on.square") - }) - Button(action: { Task { await saveImage(url: imageURL) } }, label: { - Label("Save", systemImage: "square.and.arrow.down") - }) - if let originalImageURL = galleryOriginalContents[index], !originalImageURL.isEmpty { - Button(action: { Task { await saveImage(url: originalImageURL) } }, label: { - Label("Save original", systemImage: "square.and.arrow.down.on.square") - }) - } - Button(action: { Task { await shareImage(url: imageURL) } }, label: { - Label("Share", systemImage: "square.and.arrow.up") - }) - } - } - // MARK: ConditionalList - @ViewBuilder private var conditionalList: some View { - if setting.readingDirection == .vertical { - AdvancedList( - page: page, data: containerDataSource, - id: \.self, spacing: setting.contentDividerHeight, - gesture: SimultaneousGesture(magnifyGesture, tapGesture), - content: imageContainer - ) - .disabled(scale != 1) - } else { - Pager( - page: page, data: containerDataSource, - id: \.self, content: imageContainer - ) - .horizontal( - setting.readingDirection == .rightToLeft - ? .rightToLeft : .leftToRight - ) - .swipeInteractionArea(.allAvailable) - .allowsDragging(scale == 1) - } - } - // MARK: Sheet - private func sheet(item: ReadingViewSheetState) -> some View { - Group { - switch item { - case .setting: - NavigationView { - ReadingSettingView().tint(accentColor) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - if !DeviceUtil.isPad && DeviceUtil.isLandscape { - Button { - sheetState = nil - } label: { - Image(systemName: "chevron.down") - } - } + .toolbar { + CustomToolbarItem(placement: .cancellationAction) { + if !DeviceUtil.isPad && DeviceUtil.isLandscape { + Button { + viewStore.send(.setNavigation(nil)) + } label: { + Image(systemSymbol: .chevronDown) } } + } } } - } - .accentColor(accentColor) - .blur(radius: environment.blurRadius) - .allowsHitTesting(environment.isAppUnlocked) - } -} - -private extension ReadingView { - var galleryContents: [Int: String] { - contentInfo.contents[gid] ?? [:] - } - var galleryOriginalContents: [Int: String] { - contentInfo.originalContents[gid] ?? [:] - } - var galleryLoadingFlags: [Int: Bool] { - contentInfo.contentsLoading[gid] ?? [:] - } - var galleryLoadErrors: [Int: AppError] { - contentInfo.contentsLoadErrors[gid] ?? [:] - } - - // MARK: Life Cycle - func onStartTasks() { - trySetOrientation(allowsLandscape: true, shouldChangeOrientation: true) - restoreReadingProgress() - trySetNavigationBarHidden() - fetchGalleryContentsIfNeeded() - } - func onEndTasks() { - trySaveReadingProgress() - autoPlayPolicy = .never - trySetOrientation(allowsLandscape: false) - } - func trySetOrientation(allowsLandscape: Bool, shouldChangeOrientation: Bool = false) { - guard !DeviceUtil.isPad, setting.prefersLandscape else { return } - if allowsLandscape { - AppDelegate.orientationLock = .all - if shouldChangeOrientation { - UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") - } - } else { - AppDelegate.orientationLock = [.portrait, .portraitUpsideDown] - UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") - } - UINavigationController.attemptRotationToDeviceOrientation() - } - func restoreReadingProgress() { - AppUtil.dispatchMainSync { - let index = mapToPager(index: galleryState.readingProgress) - page.update(.new(index: index)) - } - } - - // MARK: Progress - func tryUpdatePagerIndex(_: Any? = nil) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - let newIndex = mapToPager(index: Int(sliderValue)) - guard page.index != newIndex else { return } - page.update(.new(index: newIndex)) - } - } - func updateSliderValue(newIndex: Int) { - tryPrefetchImages(index: newIndex) - let newValue = Float(mapFromPager(index: newIndex)) - withAnimation { - if sliderValue != newValue { - sliderValue = newValue - } - } - } - func reconfigureTimer(newPolicy: AutoPlayPolicy) { - autoPlayTimer?.invalidate() - guard newPolicy != .never else { return } - autoPlayTimer = Timer.scheduledTimer( - withTimeInterval: TimeInterval(newPolicy.rawValue), - repeats: true, block: tryUpdatePagerIndexByTimer + .accentColor(setting.accentColor).tint(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /ReadingState.Route.share) { route in + ActivityView(activityItems: [route.wrappedValue]) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .progressHUD( + config: viewStore.hudConfig, + unwrapping: viewStore.binding(\.$route), + case: /ReadingState.Route.hud ) - } - func tryUpdatePagerIndexByTimer(_: Timer) { - guard Int(sliderValue) < pageCount else { - autoPlayPolicy = .never - return - } - page.update(.next) - } - func trySaveReadingProgress() { - let progress = mapFromPager(index: page.index) - guard progress > 0 else { return } - store.dispatch(.setReadingProgress(gid: gid, tag: progress)) - } - func mapToPager(index: Int) -> Int { - guard DeviceUtil.isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return index - 1 } - guard index > 1 else { return 0 } - - return setting.exceptCover ? index / 2 : (index - 1) / 2 - } - func mapFromPager(index: Int) -> Int { - guard DeviceUtil.isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return index + 1 } - guard index > 0 else { return 1 } - - let result = setting.exceptCover ? index * 2 : index * 2 + 1 - - if result + 1 == pageCount { - return pageCount - } else { - return result - } - } - - // MARK: Dispatch - func tryFetchGalleryContents(index: Int = 1) { - guard galleryContents[index] == nil else { return } - if contentInfo.mpvKeys[gid] != nil { - store.dispatch(.fetchGalleryMPVContent(gid: gid, index: index)) - } else { - store.dispatch(.fetchThumbnails(gid: gid, index: index)) - } - } - func refetchGalleryContents(index: Int) { - if contentInfo.mpvKeys[gid] != nil { - store.dispatch(.fetchGalleryMPVContent(gid: gid, index: index, isRefetch: true)) - } else { - store.dispatch(.refetchGalleryNormalContent(gid: gid, index: index)) - } - } - func fetchGalleryPreivews(index: Int) { - store.dispatch(.fetchGalleryPreviews(gid: gid, index: index)) - } - - func presentSettingSheet() { - sheetState = .setting - autoPlayPolicy = .never - HapticUtil.generateFeedback(style: .light) - } - func updateSetting(_ setting: Setting) { - store.dispatch(.setSetting(setting)) - } - func fetchGalleryContentsIfNeeded() { - guard galleryContents.isEmpty else { return } - tryFetchGalleryContents() - } - func trySetNavigationBarHidden(_: Any? = nil) { - guard !environment.navigationBarHidden else { return } - store.dispatch(.setNavigationBarHidden(true)) - } - - // MARK: Prefetch - func tryPrefetchImages(index: Int) { - var prefetchIndices = [URL]() - let prefetchLimit = setting.prefetchLimit / 2 - - let previousUpperBound = max(index - 2, 1) - let previousLowerBound = max(previousUpperBound - prefetchLimit, 1) - if previousUpperBound - previousLowerBound > 0 { - appendPrefetchIndices( - array: &prefetchIndices, - range: previousLowerBound...previousUpperBound - ) - } - - let nextLowerBound = min(index + 2, pageCount) - let nextUpperBound = min(nextLowerBound + prefetchLimit, pageCount) - if nextUpperBound - nextLowerBound > 0 { - appendPrefetchIndices( - array: &prefetchIndices, - range: nextLowerBound...nextUpperBound + // These bindings couldn't be done in Store since It doesn't have enough infos + .synchronize(viewStore.binding(\.$pageIndex), $page.index) + .onChange(of: viewStore.pageIndex) { pageIndex in + let newValue = viewStore.state.mapFromPager( + index: pageIndex, setting: setting ) - } - - guard !prefetchIndices.isEmpty else { return } - ImagePrefetcher(urls: prefetchIndices).start() - } - - func appendPrefetchIndices(array: inout [URL], range: ClosedRange) { - let indices = Array(range.lowerBound...range.upperBound) - array.append(contentsOf: indices.compactMap { index in - tryFetchGalleryContents(index: index) - return URL(string: galleryContents[index] ?? "") - }) - } - - // MARK: ContextMenu - func copyImage(url: String) async { - guard let image = try? await imageSaver.retrieveImage(url: url.safeURL()) else { - presentHUD(isSuccess: false) - return - } - UIPasteboard.general.image = image - presentHUD(isSuccess: true, caption: "Copied to clipboard") - } - func saveImage(url: String) async { - guard let image = try? await imageSaver.retrieveImage(url: url.safeURL()) else { - presentHUD(isSuccess: false) - return - } - imageSaver.saveImage(image) - } - func shareImage(url: String) async { - guard let image = try? await imageSaver.retrieveImage(url: url.safeURL()) else { - presentHUD(isSuccess: false) - return - } - AppUtil.presentActivity(items: [image]) - } - func presentHUD(isSuccess: Bool, caption: String? = nil) { - let type: TTProgressHUDType = isSuccess ? .success : .error - let title = (isSuccess ? "Success" : "Error").localized - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - switch type { - case .success: - HapticUtil.generateNotificationFeedback(style: .success) - case .error: - HapticUtil.generateNotificationFeedback(style: .error) - default: - break + viewStore.send(.setSliderValue(.init(newValue))) + if pageIndex != 0 { + viewStore.send(.syncReadingProgress) } - - hudConfig = TTProgressHUDConfig( - type: type, title: title, caption: caption?.localized, - shouldAutoHide: true, autoHideInterval: 1 - ) - hudVisible = true } + .onChange(of: viewStore.sliderValue) { sliderValue in + let newValue = viewStore.state.mapToPager( + index: .init(sliderValue), setting: setting + ) + page.update(.new(index: newValue)) + } + .onChange(of: setting.enablesLandscape) { + viewStore.send(.setOrientationPortrait(!$0)) + } + .animation(.default, value: viewStore.showsPanel) + .animation(.default, value: viewStore.pageIndex) + .animation(.default, value: viewStore.scale) + .statusBar(hidden: !viewStore.showsPanel) + .onAppear { viewStore.send(.onAppear(setting.enablesLandscape)) } + } + + @ViewBuilder private func imageStack(index: Int) -> some View { + let imageStackConfig = viewStore.state.imageContainerConfigs(index: index, setting: setting) + let isDualPage = setting.enablesDualPageMode && setting.readingDirection != .vertical && DeviceUtil.isLandscape + HorizontalImageStack( + index: index, isDualPage: isDualPage, isDatabaseLoading: viewStore.databaseLoadingState != .idle, + backgroundColor: backgroundColor, config: imageStackConfig, imageURLs: viewStore.imageURLs, + originalImageURLs: viewStore.originalImageURLs, loadingStates: viewStore.imageURLLoadingStates, + fetchAction: { viewStore.send(.fetchImageURLs($0)) }, + refetchAction: { viewStore.send(.refetchImageURLs($0)) }, + prefetchAction: { viewStore.send(.prefetchImages($0, setting.prefetchLimit)) }, + loadRetryAction: { viewStore.send(.onWebImageRetry($0)) }, + loadSucceededAction: { viewStore.send(.onWebImageSucceeded($0)) }, + loadFailedAction: { viewStore.send(.onWebImageFailed($0)) }, + copyImageAction: { viewStore.send(.copyImage($0)) }, + saveImageAction: { viewStore.send(.saveImage($0)) }, + shareImageAction: { viewStore.send(.shareImage($0)) } + ) } +} - // MARK: Gesture +// MARK: Gesture +extension ReadingView { var tapGesture: some Gesture { - let singleTap = TapGesture(count: 1).onEnded { _ in - let defaultAction = { withAnimation { showsPanel.toggle() } } - guard setting.readingDirection != .vertical, - let pointX = TouchHandler.shared.currentPoint?.x - else { - defaultAction() - return - } - let rightToLeft = setting.readingDirection == .rightToLeft - if pointX < DeviceUtil.absWindowW * 0.2 { - page.update(rightToLeft ? .next : .previous) - } else if pointX > DeviceUtil.absWindowW * (1 - 0.2) { - page.update(rightToLeft ? .previous : .next) - } else { - defaultAction() + let singleTap = TapGesture(count: 1) + .onEnded { viewStore.send(.onSingleTapGestureEnded(setting.readingDirection)) } + let doubleTap = TapGesture(count: 2) + .onEnded { + viewStore.send(.onDoubleTapGestureEnded( + setting.maximumScaleFactor, setting.doubleTapScaleFactor + )) } - } - let doubleTap = TapGesture(count: 2).onEnded { _ in - trySyncScaleAnchor() - trySetOffset(.zero) - trySetScale(scale == 1 ? setting.doubleTapScaleFactor : 1) - } return ExclusiveGesture(doubleTap, singleTap) } - var magnifyGesture: some Gesture { + var magnificationGesture: some Gesture { MagnificationGesture() - .onChanged(onMagnificationGestureChanged) - .onEnded(onMagnificationGestureEnded) + .onChanged { viewStore.send(.onMagnificationGestureChanged($0, setting.maximumScaleFactor)) } + .onEnded { viewStore.send(.onMagnificationGestureEnded($0, setting.maximumScaleFactor)) } } var dragGesture: some Gesture { DragGesture(minimumDistance: 0.0, coordinateSpace: .local) - .onChanged(onDragGestureChanged).onEnded(onDragGestureEnded) + .onChanged { viewStore.send(.onDragGestureChanged($0)) } + .onEnded { viewStore.send(.onDragGestureEnded($0)) } } +} - func onDragGestureChanged(value: DragGesture.Value) { - guard scale > 1 else { return } - - let newX = value.translation.width + newOffset.width - let newY = value.translation.height + newOffset.height - let newOffsetW = fixWidth(x: newX) - let newOffsetH = fixHeight(y: newY) - - trySetOffset(CGSize(width: newOffsetW, height: newOffsetH)) - } - func onDragGestureEnded(value: DragGesture.Value) { - onDragGestureChanged(value: value) +// 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 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 - if scale > 1 { - newOffset.width = offset.width - newOffset.height = offset.height - } - } - func onMagnificationGestureChanged(value: MagnificationGesture.Value) { - if value == 1 { - baseScale = scale - } - trySyncScaleAnchor() - trySetScale(value * baseScale) - } - func onMagnificationGestureEnded(value: MagnificationGesture.Value) { - onMagnificationGestureChanged(value: value) - if value * baseScale - 1 < 0.01 { - trySetScale(1) - } - baseScale = scale + init( + index: Int, isDualPage: Bool, isDatabaseLoading: Bool, backgroundColor: Color, + config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], + loadingStates: [Int: LoadingState], 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.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 } - func trySetOffset(_ newValue: CGSize) { - let animation = Animation.linear(duration: 0.1) - guard offset != newValue else { return } - withAnimation(animation) { - offset = newValue + var body: some View { + HStack(spacing: 0) { + if config.isFirstAvailable { + imageContainer(index: config.firstIndex) + } + if config.isSecondAvailable { + imageContainer(index: config.secondIndex) + } } - fixOffset() - } - func trySetScale(_ newValue: CGFloat) { - let max = setting.maximumScaleFactor - guard scale != newValue && newValue >= 1 && newValue <= max else { return } - withAnimation { - scale = newValue + func imageContainer(index: Int) -> some View { + ImageContainer( + index: index, + imageURL: imageURLs[index], + loadingState: loadingStates[index] ?? .idle, + isDualPage: isDualPage, + backgroundColor: backgroundColor, + refetchAction: refetchAction, + loadRetryAction: loadRetryAction, + loadSucceededAction: loadSucceededAction, + loadFailedAction: loadFailedAction + ) + .onAppear { + if !isDatabaseLoading { + if imageURLs[index] == nil { + fetchAction(index) + } + prefetchAction(index) + } } - fixOffset() - } - func trySyncScaleAnchor() { - guard let point = TouchHandler.shared.currentPoint else { return } - - let x = min(max(point.x / DeviceUtil.absWindowW, 0), 1) - let y = min(max(point.y / DeviceUtil.absWindowH, 0), 1) - scaleAnchor = UnitPoint(x: x, y: y) + .contextMenu { contextMenuItems(index: index) } } - func fixOffset() { - withAnimation { - offset.width = fixWidth(x: offset.width) - offset.height = fixHeight(y: offset.height) + @ViewBuilder private func contextMenuItems(index: Int) -> some View { + Button { + refetchAction(index) + } label: { + Label(R.string.localizable.readingViewContextMenuButtonReload(), systemSymbol: .arrowCounterclockwise) + } + if let imageURL = imageURLs[index] { + Button { + copyImageAction(imageURL) + } label: { + Label(R.string.localizable.readingViewContextMenuButtonCopy(), systemSymbol: .plusSquareOnSquare) + } + Button { + saveImageAction(imageURL) + } label: { + Label(R.string.localizable.readingViewContextMenuButtonSave(), systemSymbol: .squareAndArrowDown) + } + if let originalImageURL = originalImageURLs[index] { + Button { + saveImageAction(originalImageURL) + } label: { + Label( + R.string.localizable.readingViewContextMenuButtonSaveOriginal(), + systemSymbol: .squareAndArrowDownOnSquare + ) + } + } + Button { + shareImageAction(imageURL) + } label: { + Label(R.string.localizable.readingViewContextMenuButtonShare(), systemSymbol: .squareAndArrowUp) + } } } - func fixWidth(x: CGFloat) -> CGFloat { - 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) - } - func fixHeight(y: CGFloat) -> CGFloat { - 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) - } } // MARK: ImageContainer private struct ImageContainer: View { - @Environment(\.colorScheme) private var colorScheme - private var backgroundColor: Color { - colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) - } - - @State private var webImageLoadFailed = false - - private var reloadSymbolName: String = - "exclamationmark.arrow.triangle.2.circlepath" private var width: CGFloat { DeviceUtil.windowW / (isDualPage ? 2 : 1) } private var height: CGFloat { width / Defaults.ImageSize.contentAspect } - private var loadFailedFlag: Bool { - loadError != nil || webImageLoadFailed - } private let index: Int - private let imageURL: String + private let imageURL: URL? + private let loadingState: LoadingState private let isDualPage: Bool - private let loadingFlag: Bool - private let loadError: AppError? - private let retryAction: (Int) -> Void - private let reloadAction: (Int) -> Void + private let backgroundColor: Color + private let refetchAction: (Int) -> Void + private let loadRetryAction: (Int) -> Void + private let loadSucceededAction: (Int) -> Void + private let loadFailedAction: (Int) -> Void init( - index: Int, imageURL: String, loadingFlag: Bool, - loadError: AppError?, isDualPage: Bool, - retryAction: @escaping (Int) -> Void, - reloadAction: @escaping (Int) -> Void + index: Int, imageURL: URL?, + loadingState: LoadingState, + isDualPage: Bool, backgroundColor: Color, + refetchAction: @escaping (Int) -> Void, + loadRetryAction: @escaping (Int) -> Void, + loadSucceededAction: @escaping (Int) -> Void, + loadFailedAction: @escaping (Int) -> Void ) { self.index = index self.imageURL = imageURL - self.loadingFlag = loadingFlag - self.loadError = loadError + self.loadingState = loadingState self.isDualPage = isDualPage - self.retryAction = retryAction - self.reloadAction = reloadAction + self.backgroundColor = backgroundColor + self.refetchAction = refetchAction + self.loadRetryAction = loadRetryAction + self.loadSucceededAction = loadSucceededAction + self.loadFailedAction = loadFailedAction } private func placeholder(_ progress: Progress) -> some View { @@ -643,82 +344,72 @@ private struct ImageContainer: View { )) .frame(width: width, height: height) } - private func retryView() -> some View { - ZStack { - backgroundColor - VStack { - Text(String(index)) - .fontWeight(.bold).font(.largeTitle) - .foregroundColor(.gray).padding(.bottom, 30) - if loadFailedFlag { - Button(action: reloadImage) { - Image(systemName: reloadSymbolName) - } - .font(.system(size: 30, weight: .medium)) - .foregroundColor(.gray) - } else { - ProgressView() - } - } - } - .frame(width: width, height: height) - } - @ViewBuilder private func image(url: String) -> some View { - if !imageURL.contains(".gif") { - KFImage(URL(string: imageURL)) + @ViewBuilder private func image(url: URL?) -> some View { + if url?.absoluteString.contains(".gif") != true { + KFImage(url) .placeholder(placeholder) .defaultModifier(withRoundedCorners: false) .onSuccess(onSuccess).onFailure(onFailure) } else { - KFAnimatedImage(URL(string: imageURL)) + KFAnimatedImage(url) .placeholder(placeholder).fade(duration: 0.25) .onSuccess(onSuccess).onFailure(onFailure) } } var body: some View { - if loadingFlag || loadFailedFlag { - retryView() - .onChange(of: imageURL) { _ in - webImageLoadFailed = false - } - } else { + if loadingState == .idle { image(url: imageURL).scaledToFit() + } 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) + } + } + } + .frame(width: width, height: height) } } private func reloadImage() { - if webImageLoadFailed { - reloadAction(index) - } else if loadError != nil { - retryAction(index) + if let error = (/LoadingState.failed).extract(from: loadingState) { + if case .webImageFailed = error { + loadRetryAction(index) + } else { + refetchAction(index) + } } } private func onSuccess(_: RetrieveImageResult) { - webImageLoadFailed = false + loadSucceededAction(index) } private func onFailure(_: KingfisherError) { - guard !imageURL.isEmpty else { return } - webImageLoadFailed = true + if imageURL != nil { + loadFailedAction(index) + } } } // MARK: Definition -enum ReadingViewSheetState: Identifiable { - var id: Int { hashValue } - case setting -} - -struct ReadingView_Previews: PreviewProvider { - static var previews: some View { - PersistenceController.prepareForPreviews() - return ReadingView(gid: "").environmentObject(Store.preview) - } +struct ImageStackConfig { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool } enum AutoPlayPolicy: Int, CaseIterable, Identifiable { var id: Int { rawValue } - case never = -1 + case off = -1 case sec1 = 1 case sec2 = 2 case sec3 = 3 @@ -727,12 +418,41 @@ enum AutoPlayPolicy: Int, CaseIterable, Identifiable { } extension AutoPlayPolicy { - var descriptionKey: LocalizedStringKey { + var value: String { switch self { - case .never: - return "Never" + case .off: + return R.string.localizable.enumAutoPlayPolicyValueOff() default: - return "\(rawValue) seconds" + return R.string.localizable.commonValueSeconds("\(rawValue)") + } + } +} + +struct ReadingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + Text("") + .fullScreenCover(isPresented: .constant(true)) { + ReadingView( + store: .init( + initialState: .init(gallery: .empty), + reducer: readingReducer, + environment: ReadingEnvironment( + urlClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live + ) + ), + setting: .constant(.init()), + blurRadius: 0, + dismissAction: {} + ) + } } } } diff --git a/EhPanda/View/Reading/AdvancedList.swift b/EhPanda/View/Reading/Support/AdvancedList.swift similarity index 81% rename from EhPanda/View/Reading/AdvancedList.swift rename to EhPanda/View/Reading/Support/AdvancedList.swift index 753fe2df..d9dd161e 100644 --- a/EhPanda/View/Reading/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -53,20 +53,21 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { private func longPressGesture(index: Element) -> some Gesture { LongPressGesture(minimumDuration: 0, maximumDistance: .infinity) .onEnded { _ in - guard let index = index as? Int else { return } - - performingChanges = true - pagerModel.update(.new(index: index - 1)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - performingChanges = false + if let index = index as? Int { + performingChanges = true + pagerModel.update(.new(index: index - 1)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + performingChanges = false + } } } } private func tryScrollTo(id: Int, proxy: ScrollViewProxy) { - guard !performingChanges else { return } - AppUtil.dispatchMainSync { - proxy.scrollTo(id, anchor: .center) + if !performingChanges { + AppUtil.dispatchMainSync { + proxy.scrollTo(id, anchor: .center) + } } } } diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift new file mode 100644 index 00000000..32e88023 --- /dev/null +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -0,0 +1,288 @@ +// +// ControlPanel.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/07/30. +// + +import SwiftUI +import Kingfisher + +// MARK: ControlPanel +struct ControlPanel: View { + @Binding private var showsPanel: Bool + @Binding private var showsSliderPreview: Bool + @Binding private var sliderValue: Float + @Binding private var setting: Setting + @Binding private var autoPlayPolicy: AutoPlayPolicy + private let range: ClosedRange + private let previewURLs: [Int: URL] + private let dismissAction: () -> Void + private let navigateSettingAction: () -> Void + private let fetchPreviewURLsAction: (Int) -> Void + + init( + showsPanel: Binding, showsSliderPreview: Binding, sliderValue: Binding, + setting: Binding, autoPlayPolicy: Binding, range: ClosedRange, + previewURLs: [Int: URL], dismissAction: @escaping () -> Void, + navigateSettingAction: @escaping () -> Void, + fetchPreviewURLsAction: @escaping (Int) -> Void + ) { + _showsPanel = showsPanel + _showsSliderPreview = showsSliderPreview + _sliderValue = sliderValue + _setting = setting + _autoPlayPolicy = autoPlayPolicy + self.range = range + self.previewURLs = previewURLs + self.dismissAction = dismissAction + self.navigateSettingAction = navigateSettingAction + self.fetchPreviewURLsAction = fetchPreviewURLsAction + } + + private var title: String { + ["\(max(Int(sliderValue), 1))", "\(Int(range.upperBound))"].joined(separator: " / ") + } + + var body: some View { + VStack { + UpperPanel( + title: title, + setting: $setting, + autoPlayPolicy: $autoPlayPolicy, + dismissAction: dismissAction, + navigateSettingAction: navigateSettingAction + ) + .offset(y: showsPanel ? 0 : -50) + Spacer() + if range.upperBound > range.lowerBound { + LowerPanel( + showsSliderPreview: $showsSliderPreview, + sliderValue: $sliderValue, previewURLs: previewURLs, range: range, + isReversed: setting.readingDirection == .rightToLeft, + fetchPreviewURLsAction: fetchPreviewURLsAction + ) + .animation(.default, value: showsSliderPreview) + .offset(y: showsPanel ? 0 : 50) + } + } + .opacity(showsPanel ? 1 : 0).disabled(!showsPanel) + } +} + +// MARK: UpperPanel +private struct UpperPanel: View { + @Binding private var setting: Setting + @Binding private var autoPlayPolicy: AutoPlayPolicy + + private let title: String + private let dismissAction: () -> Void + private let navigateSettingAction: () -> Void + + init( + title: String, setting: Binding, + autoPlayPolicy: Binding, + dismissAction: @escaping () -> Void, + navigateSettingAction: @escaping () -> Void + ) { + self.title = title + _setting = setting + _autoPlayPolicy = autoPlayPolicy + self.dismissAction = dismissAction + self.navigateSettingAction = navigateSettingAction + } + + var body: some View { + ZStack { + HStack { + Button(action: dismissAction) { + Image(systemSymbol: .chevronDown) + } + .font(.title2).padding(.leading, 20) + Spacer() + Slider(value: .constant(0)).opacity(0) + Spacer() + HStack(spacing: 20) { + if DeviceUtil.isLandscape && setting.readingDirection != .vertical { + Menu { + Button { + setting.enablesDualPageMode.toggle() + } label: { + Text(R.string.localizable.readingViewToolbarItemTitleDualPageMode()) + if setting.enablesDualPageMode { + Image(systemSymbol: .checkmark) + } + } + Button { + setting.exceptCover.toggle() + } label: { + Text(R.string.localizable.readingViewToolbarItemTitleExceptTheCover()) + if setting.exceptCover { + Image(systemSymbol: .checkmark) + } + } + .disabled(!setting.enablesDualPageMode) + } label: { + Image(systemSymbol: .rectangleSplit2x1) + .symbolVariant(setting.enablesDualPageMode ? .fill : .none) + } + } + Menu { + Text(R.string.localizable.readingViewToolbarItemTitleAutoPlay()).foregroundColor(.secondary) + ForEach(AutoPlayPolicy.allCases) { policy in + Button { + autoPlayPolicy = policy + } label: { + Text(policy.value) + if autoPlayPolicy == policy { + Image(systemSymbol: .checkmark) + } + } + } + } label: { + Image(systemSymbol: .timer) + } + Button(action: navigateSettingAction) { + Image(systemSymbol: .gear) + } + .padding(.trailing, 20) + } + .font(.title2) + } + Text(title).bold().lineLimit(1).padding() + } + .background(.thinMaterial) + } +} + +// MARK: LowerPanel +private struct LowerPanel: View { + @Binding private var showsSliderPreview: Bool + @Binding private var sliderValue: Float + private let previewURLs: [Int: URL] + private let range: ClosedRange + private let isReversed: Bool + private let fetchPreviewURLsAction: (Int) -> Void + + init( + showsSliderPreview: Binding, sliderValue: Binding, + previewURLs: [Int: URL], range: ClosedRange, isReversed: Bool, + fetchPreviewURLsAction: @escaping (Int) -> Void + ) { + _showsSliderPreview = showsSliderPreview + _sliderValue = sliderValue + self.previewURLs = previewURLs + self.range = range + self.isReversed = isReversed + self.fetchPreviewURLsAction = fetchPreviewURLsAction + } + + var body: some View { + VStack(spacing: 0) { + SliderPreivew( + showsSliderPreview: $showsSliderPreview, + sliderValue: $sliderValue, previewURLs: previewURLs, range: range, + isReversed: isReversed, fetchPreviewURLsAction: fetchPreviewURLsAction + ) + VStack { + HStack { + Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") + .fontWeight(.medium).font(.caption).padding() + Slider( + value: $sliderValue, in: range, step: 1, + onEditingChanged: { showsSliderPreview = $0 } + ) + .rotationEffect(.init(degrees: isReversed ? 180 : 0)) + Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") + .fontWeight(.medium).font(.caption).padding() + } + .padding(.horizontal).padding(.bottom) + } + } + .background(.thinMaterial) + } +} + +// MARK: SliderPreview +private struct SliderPreivew: View { + @Binding private var showsSliderPreview: Bool + @Binding var sliderValue: Float + private let previewURLs: [Int: URL] + private let range: ClosedRange + private let isReversed: Bool + private let fetchPreviewURLsAction: (Int) -> Void + + init( + showsSliderPreview: Binding, sliderValue: Binding, + previewURLs: [Int: URL], range: ClosedRange, + isReversed: Bool, fetchPreviewURLsAction: @escaping (Int) -> Void + ) { + _showsSliderPreview = showsSliderPreview + _sliderValue = sliderValue + self.previewURLs = previewURLs + self.range = range + self.isReversed = isReversed + self.fetchPreviewURLsAction = fetchPreviewURLsAction + } + + 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() + .frame(width: previewWidth, height: showsSliderPreview ? previewHeight : 0) + Text("\(index)").font(DeviceUtil.isPadWidth ? .callout : .caption) + .foregroundColor(index == Int(sliderValue) ? .accentColor : .secondary) + } + .onAppear { + if previewURLs[index] == nil && checkIndex(index) { + fetchPreviewURLsAction(index) + } + } + .opacity(checkIndex(index) ? 1 : 0) + } + } + .opacity(showsSliderPreview ? 1 : 0).padding(.vertical, verticalPadding) + .frame(height: showsSliderPreview ? previewHeight + verticalPadding * 2 : 0) + } +} + +private extension SliderPreivew { + var verticalPadding: CGFloat { + DeviceUtil.isPadWidth ? 30 : 20 + } + var previewsCount: Int { + DeviceUtil.isPadWidth ? DeviceUtil.isLandscape ? 7 : 5 : 3 + } + var previewsIndices: [Int] { + guard !previewURLs.isEmpty else { return [] } + let currentIndex = Int(sliderValue) + let distance = (previewsCount - 1) / 2 + let lowerBound = currentIndex - distance + let upperBound = currentIndex + distance + + let indices = Array(lowerBound...upperBound) + return isReversed ? indices.reversed() : indices + } + var previewSpacing: CGFloat { 10 } + var previewHeight: CGFloat { + previewWidth / Defaults.ImageSize.previewAspect + } + var previewWidth: CGFloat { + guard previewsCount > 0 else { return 0 } + let count = CGFloat(previewsCount) + let spacing = (count + 1) * previewSpacing + return (DeviceUtil.windowW - spacing) / count + } + func checkIndex(_ index: Int) -> Bool { + index >= Int(range.lowerBound) && index <= Int(range.upperBound) + } +} diff --git a/EhPanda/View/Reading/MeasureTool.swift b/EhPanda/View/Reading/Support/MeasureTool.swift similarity index 100% rename from EhPanda/View/Reading/MeasureTool.swift rename to EhPanda/View/Reading/Support/MeasureTool.swift diff --git a/EhPanda/View/Search/SearchRootStore.swift b/EhPanda/View/Search/SearchRootStore.swift new file mode 100644 index 00000000..9c6d40bd --- /dev/null +++ b/EhPanda/View/Search/SearchRootStore.swift @@ -0,0 +1,248 @@ +// +// SearchRootStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct SearchRootState: Equatable { + enum Route: Equatable { + case search + case filters + case quickSearch + case detail(String) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + var historyGalleries = [Gallery]() + + var historyKeywords = [String]() + var quickSearchWords = [QuickSearchWord]() + + var searchState = SearchState() + var filtersState = FiltersState() + var quickSearchState = QuickSearchState() + @Heap var detailState: DetailState! + + mutating func appendHistoryKeywords(_ keywords: [String]) { + guard !keywords.isEmpty else { return } + var historyKeywords = historyKeywords + + keywords.forEach { keyword in + guard !keyword.isEmpty else { return } + if let index = historyKeywords.firstIndex(where: { + $0.caseInsensitiveEqualsTo(keyword) + }) { + if historyKeywords.last != keyword { + historyKeywords.remove(at: index) + historyKeywords.append(keyword) + } + } else { + historyKeywords.append(keyword) + let overflow = historyKeywords.count - 20 + if overflow > 0 { + historyKeywords = Array( + historyKeywords.dropFirst(overflow) + ) + } + } + } + self.historyKeywords = historyKeywords + } + mutating func removeHistoryKeyword(_ keyword: String) { + historyKeywords = historyKeywords.filter { $0 != keyword } + } +} + +enum SearchRootAction: BindableAction { + case binding(BindingAction) + case setNavigation(SearchRootState.Route?) + case setKeyword(String) + case clearSubStates + + case syncHistoryKeywords + case fetchDatabaseInfos + case fetchDatabaseInfosDone(AppEnv) + case appendHistoryKeyword(String) + case removeHistoryKeyword(String) + case fetchHistoryGalleries + case fetchHistoryGalleriesDone([Gallery]) + + case search(SearchAction) + case filters(FiltersAction) + case quickSearch(QuickSearchAction) + case detail(DetailAction) +} + +struct SearchRootEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let searchRootReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil + ? .merge( + .init(value: .clearSubStates), + .init(value: .fetchDatabaseInfos) + ) + : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil + ? .merge( + .init(value: .clearSubStates), + .init(value: .fetchDatabaseInfos) + ) + : .none + + case .setKeyword(let keyword): + state.keyword = keyword + return .none + + case .clearSubStates: + state.searchState = .init() + state.detailState = .init() + state.filtersState = .init() + state.quickSearchState = .init() + return .merge( + .init(value: .search(.teardown)), + .init(value: .quickSearch(.teardown)), + .init(value: .detail(.teardown)) + ) + + case .syncHistoryKeywords: + return environment.databaseClient.updateHistoryKeywords(state.historyKeywords).fireAndForget() + + case .fetchDatabaseInfos: + return environment.databaseClient.fetchAppEnv().map(SearchRootAction.fetchDatabaseInfosDone) + + case .fetchDatabaseInfosDone(let appEnv): + state.historyKeywords = appEnv.historyKeywords + state.quickSearchWords = appEnv.quickSearchWords + return .none + + case .appendHistoryKeyword(let keyword): + state.appendHistoryKeywords([keyword]) + return .init(value: .syncHistoryKeywords) + + case .removeHistoryKeyword(let keyword): + state.removeHistoryKeyword(keyword) + return .init(value: .syncHistoryKeywords) + + case .fetchHistoryGalleries: + return environment.databaseClient + .fetchHistoryGalleries(fetchLimit: 10).map(SearchRootAction.fetchHistoryGalleriesDone) + + case .fetchHistoryGalleriesDone(let galleries): + state.historyGalleries = Array(galleries.prefix(min(galleries.count, 10))) + return .none + + case .search(.fetchGalleries(_, let keyword)): + if let keyword = keyword { + state.appendHistoryKeywords([keyword]) + } else { + state.appendHistoryKeywords([state.searchState.lastKeyword]) + } + return .init(value: .syncHistoryKeywords) + + case .search: + return .none + + case .filters: + return .none + + case .quickSearch: + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /SearchRootState.Route.quickSearch, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /SearchRootState.Route.filters, + hapticClient: \.hapticClient + ) + .binding(), + searchReducer.pullback( + state: \.searchState, + action: /SearchRootAction.search, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + filtersReducer.pullback( + state: \.filtersState, + action: /SearchRootAction.filters, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + quickSearchReducer.pullback( + state: \.quickSearchState, + action: /SearchRootAction.quickSearch, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + detailReducer.pullback( + state: \.detailState, + action: /SearchRootAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift new file mode 100644 index 00000000..c815dfe0 --- /dev/null +++ b/EhPanda/View/Search/SearchRootView.swift @@ -0,0 +1,424 @@ +// +// SearchRootView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import SwiftUI +import ComposableArchitecture + +struct SearchRootView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + private var searchFieldPlacement: SearchFieldPlacement { + DeviceUtil.isPad ? .automatic : .navigationBarDrawer(displayMode: .always) + } + + var body: some View { + NavigationView { + ScrollView(showsIndicators: false) { + SuggestionsPanel( + historyKeywords: viewStore.historyKeywords.reversed(), + historyGalleries: viewStore.historyGalleries, + quickSearchWords: viewStore.quickSearchWords, + navigateGalleryAction: { viewStore.send(.setNavigation(.detail($0))) }, + navigateQuickSearchAction: { viewStore.send(.setNavigation(.quickSearch)) }, + searchKeywordAction: { keyword in + viewStore.send(.setKeyword(keyword)) + viewStore.send(.setNavigation(.search)) + }, + removeKeywordAction: { viewStore.send(.removeHistoryKeyword($0)) } + ) + } + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /SearchRootState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: SearchRootAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchRootState.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: SearchRootAction.filters)) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchRootState.Route.quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: SearchRootAction.quickSearch) + ) { keyword in + viewStore.send(.setNavigation(nil)) + viewStore.send(.setKeyword(keyword)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewStore.send(.setNavigation(.search)) + } + } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: viewStore.binding(\.$keyword), placement: searchFieldPlacement) + .onSubmit(of: .search) { + viewStore.send(.setNavigation(.search)) + } + .onAppear { + viewStore.send(.fetchHistoryGalleries) + viewStore.send(.fetchDatabaseInfos) + } + .background(navigationLinks) + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.searchViewTitleSearch()) + } + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(tint: .primary) { + ToolbarFeaturesMenu(symbolRenderingMode: .hierarchical) { + FiltersButton { + viewStore.send(.setNavigation(.filters)) + } + QuickSearchButton { + viewStore.send(.setNavigation(.quickSearch)) + } + } + } + } +} + +private extension SearchRootView { + @ViewBuilder var navigationLinks: some View { + if DeviceUtil.isPhone { + detailViewLink + } + searchViewLink + } + var detailViewLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchRootState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: SearchRootAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + var searchViewLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchRootState.Route.search) { _ in + SearchView( + store: store.scope(state: \.searchState, action: SearchRootAction.search), + keyword: viewStore.keyword, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } +} + +// MARK: SuggestionsPanel +private struct SuggestionsPanel: View { + private let historyKeywords: [String] + private let historyGalleries: [Gallery] + private let quickSearchWords: [QuickSearchWord] + private let navigateGalleryAction: (String) -> Void + private let navigateQuickSearchAction: () -> Void + private let searchKeywordAction: (String) -> Void + private let removeKeywordAction: (String) -> Void + + init( + historyKeywords: [String], historyGalleries: [Gallery], + quickSearchWords: [QuickSearchWord], + navigateGalleryAction: @escaping (String) -> Void, + navigateQuickSearchAction: @escaping () -> Void, + searchKeywordAction: @escaping (String) -> Void, + removeKeywordAction: @escaping (String) -> Void + ) { + self.historyKeywords = historyKeywords + self.historyGalleries = historyGalleries + self.quickSearchWords = quickSearchWords + self.navigateGalleryAction = navigateGalleryAction + self.navigateQuickSearchAction = navigateQuickSearchAction + self.searchKeywordAction = searchKeywordAction + self.removeKeywordAction = removeKeywordAction + } + + var body: some View { + ZStack { + VStack { + if !quickSearchWords.isEmpty { + QuickSearchWordsSection( + quickSearchWords: quickSearchWords, + showAllAction: navigateQuickSearchAction, + searchAction: searchKeywordAction + ) + } + if !historyKeywords.isEmpty { + HistoryKeywordsSection( + keywords: historyKeywords, + searchAction: searchKeywordAction, + removeAction: removeKeywordAction + ) + } + if !historyGalleries.isEmpty { + HistoryGalleriesSection( + galleries: historyGalleries, + navigationAction: navigateGalleryAction + ) + } + } + } + .animation(.default, value: quickSearchWords) + .animation(.default, value: historyGalleries) + .animation(.default, value: historyKeywords) + .padding(.vertical) + } +} + +// MARK: QuickSearchWordsSection +private struct QuickSearchWordsSection: View { + private let quickSearchWords: [QuickSearchWord] + private let showAllAction: () -> Void + private let searchAction: (String) -> Void + + init( + quickSearchWords: [QuickSearchWord], + showAllAction: @escaping () -> Void, + searchAction: @escaping (String) -> Void + ) { + self.quickSearchWords = quickSearchWords + self.showAllAction = showAllAction + self.searchAction = searchAction + } + + private var keywords: [WrappedKeyword] { + quickSearchWords.map { word in + .init(keyword: word.content, displayText: word.name) + } + .removeDuplicates() + } + + var body: some View { + SubSection( + title: R.string.localizable.searchViewSectionTitleQuickSearch(), + showAll: true, tint: .primary, showAllAction: showAllAction + ) { + DoubleVerticalKeywordsStack(keywords: keywords, searchAction: searchAction) + } + } +} + +// MARK: HistoryKeywordsSection +private struct HistoryKeywordsSection: View { + private let keywords: [String] + private let searchAction: (String) -> Void + private let removeAction: ((String) -> Void) + + init(keywords: [String], searchAction: @escaping (String) -> Void, removeAction: @escaping (String) -> Void) { + self.keywords = keywords + self.searchAction = searchAction + self.removeAction = removeAction + } + + var body: some View { + SubSection(title: R.string.localizable.searchViewSectionTitleRecentlySearched(), showAll: false) { + DoubleVerticalKeywordsStack( + keywords: keywords.map({ WrappedKeyword(keyword: $0) }), + searchAction: searchAction, + removeAction: removeAction + ) + } + } +} + +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] { + Array(keywords.prefix(min(keywords.count, 10))) + } + var doubleKeywords: ([WrappedKeyword], [WrappedKeyword]) { + let isEven = keywords.count % 2 == 0 + let halfCount = keywords.count / 2 + let trailingKeywords = Array(keywords.suffix(halfCount)) + let leadingKeywords = Array( + keywords.prefix(isEven ? halfCount : halfCount + 1) + ) + 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(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 body: some View { + HStack(spacing: 20) { + Image(systemSymbol: .magnifyingglass) + Button { + searchAction(wrappedKeyword.keyword) + } label: { + Text(wrappedKeyword.displayText ?? wrappedKeyword.keyword).lineLimit(1) + Spacer() + } + .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] + private let navigationAction: (String) -> Void + + init(galleries: [Gallery], navigationAction: @escaping (String) -> Void) { + self.galleries = galleries + self.navigationAction = navigationAction + } + + var body: some View { + SubSection(title: R.string.localizable.searchViewSectionTitleRecentlySeen(), showAll: false) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(galleries) { gallery in + Button { + navigationAction(gallery.id) + } label: { + GalleryHistoryCell(gallery: gallery) + .tint(.primary).multilineTextAlignment(.leading) + } + } + .withHorizontalSpacing() + } + } + } + } +} + +// MARK: Definition +private struct WrappedKeyword: Hashable { + let keyword: String + var displayText: String? +} + +struct SearchRootView_Previews: PreviewProvider { + static var previews: some View { + SearchRootView( + store: .init( + initialState: .init(), + reducer: searchRootReducer, + environment: SearchRootEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } +} diff --git a/EhPanda/View/Search/SearchStore.swift b/EhPanda/View/Search/SearchStore.swift new file mode 100644 index 00000000..1378966e --- /dev/null +++ b/EhPanda/View/Search/SearchStore.swift @@ -0,0 +1,255 @@ +// +// SearchStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/12. +// + +import ComposableArchitecture + +struct SearchState: Equatable { + enum Route: Equatable { + case filters + case quickSearch + case detail(String) + } + struct CancelID: Hashable { + let id = String(describing: SearchState.self) + } + + init() { + _detailState = .init(.init()) + } + + @BindableState var route: Route? + @BindableState var keyword = "" + var lastKeyword = "" + @BindableState var jumpPageIndex = "" + @BindableState var jumpPageAlertFocused = false + @BindableState var jumpPageAlertPresented = false + + var galleries = [Gallery]() + var pageNumber = PageNumber() + var loadingState: LoadingState = .idle + var footerLoadingState: LoadingState = .idle + + var filtersState = FiltersState() + @Heap var detailState: DetailState! + var quickSearchState = QuickSearchState() + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } +} + +enum SearchAction: BindableAction { + case binding(BindingAction) + case setNavigation(SearchState.Route?) + case clearSubStates + + case performJumpPage + case presentJumpPageAlert + case setJumpPageAlertFocused(Bool) + + case teardown + case fetchGalleries(Int? = nil, String? = nil) + case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + + case detail(DetailAction) + case filters(FiltersAction) + case quickSearch(QuickSearchAction) +} + +struct SearchEnvironment { + let urlClient: URLClient + let fileClient: FileClient + let imageClient: ImageClient + let deviceClient: DeviceClient + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let uiApplicationClient: UIApplicationClient +} + +let searchReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$jumpPageAlertPresented): + if !state.jumpPageAlertPresented { + state.jumpPageAlertFocused = false + } + return .none + + case .binding(\.$keyword): + if !state.keyword.isEmpty { + state.lastKeyword = state.keyword + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + state.quickSearchState = .init() + return .merge( + .init(value: .detail(.teardown)), + .init(value: .quickSearch(.teardown)) + ) + + case .performJumpPage: + guard let index = Int(state.jumpPageIndex), index > 0, index <= state.pageNumber.maximum + 1 else { + return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() + } + return .init(value: .fetchGalleries(index - 1)) + + case .presentJumpPageAlert: + state.jumpPageAlertPresented = true + return environment.hapticClient.generateFeedback(.light).fireAndForget() + + case .setJumpPageAlertFocused(let isFocused): + state.jumpPageAlertFocused = isFocused + return .none + + case .teardown: + return .cancel(id: SearchState.CancelID()) + + case .fetchGalleries(let pageNum, let keyword): + guard state.loadingState != .loading else { return .none } + if let keyword = keyword { + state.keyword = keyword + state.lastKeyword = keyword + } + state.loadingState = .loading + state.pageNumber.current = 0 + let filter = environment.databaseClient.fetchFilterSynchronously(range: .search) + return SearchGalleriesRequest(keyword: state.lastKeyword, filter: filter, pageNum: pageNum) + .effect.map(SearchAction.fetchGalleriesDone).cancellable(id: SearchState.CancelID()) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + guard pageNumber.current < pageNumber.maximum else { + state.loadingState = .failed(.notFound) + return .none + } + return .init(value: .fetchMoreGalleries) + } + state.pageNumber = pageNumber + state.galleries = galleries + return environment.databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.current + 1 <= pageNumber.maximum, + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let pageNum = pageNumber.current + 1 + let filter = environment.databaseClient.fetchFilterSynchronously(range: .search) + return MoreSearchGalleriesRequest( + keyword: state.lastKeyword, filter: filter, lastID: lastID, pageNum: pageNum + ) + .effect.map(SearchAction.fetchMoreGalleriesDone).cancellable(id: SearchState.CancelID()) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.pageNumber = pageNumber + state.insertGalleries(galleries) + + var effects: [Effect] = [ + environment.databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.current < pageNumber.maximum { + effects.append(.init(value: .fetchMoreGalleries)) + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .detail: + return .none + + case .filters: + return .none + + case .quickSearch: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /SearchState.Route.quickSearch, + hapticClient: \.hapticClient + ) + .haptics( + unwrapping: \.route, + case: /SearchState.Route.filters, + hapticClient: \.hapticClient + ) + .binding(), + filtersReducer.pullback( + state: \.filtersState, + action: /SearchAction.filters, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + quickSearchReducer.pullback( + state: \.quickSearchState, + action: /SearchAction.quickSearch, + environment: { + .init( + databaseClient: $0.databaseClient + ) + } + ), + detailReducer.pullback( + state: \.detailState, + action: /SearchAction.detail, + environment: { + .init( + urlClient: $0.urlClient, + fileClient: $0.fileClient, + imageClient: $0.imageClient, + deviceClient: $0.deviceClient, + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + databaseClient: $0.databaseClient, + clipboardClient: $0.clipboardClient, + appDelegateClient: $0.appDelegateClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift new file mode 100644 index 00000000..e1561d5a --- /dev/null +++ b/EhPanda/View/Search/SearchView.swift @@ -0,0 +1,157 @@ +// +// SearchView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/12. +// + +import SwiftUI +import ComposableArchitecture + +struct SearchView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let keyword: String + private let user: User + @Binding private var setting: Setting + private let blurRadius: Double + private let tagTranslator: TagTranslator + + init( + store: Store, + keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator + ) { + self.store = store + viewStore = ViewStore(store) + self.keyword = keyword + self.user = user + _setting = setting + self.blurRadius = blurRadius + self.tagTranslator = tagTranslator + } + + var body: some View { + GenericList( + galleries: viewStore.galleries, + setting: setting, + pageNumber: viewStore.pageNumber, + loadingState: viewStore.loadingState, + footerLoadingState: viewStore.footerLoadingState, + fetchAction: { viewStore.send(.fetchGalleries()) }, + fetchMoreAction: { viewStore.send(.fetchMoreGalleries) }, + navigateAction: { viewStore.send(.setNavigation(.detail($0))) }, + translateAction: { + tagTranslator.tryTranslate(text: $0, returnOriginal: !setting.translatesTags) + } + ) + .sheet( + unwrapping: viewStore.binding(\.$route), + case: /SearchState.Route.detail, + isEnabled: DeviceUtil.isPad + ) { route in + NavigationView { + DetailView( + store: store.scope(state: \.detailState, action: SearchAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + .autoBlur(radius: blurRadius) + .environment(\.inSheet, true) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchState.Route.quickSearch) { _ in + QuickSearchView( + store: store.scope(state: \.quickSearchState, action: SearchAction.quickSearch) + ) { keyword in + viewStore.send(.setNavigation(nil)) + viewStore.send(.fetchGalleries(nil, keyword)) + } + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchState.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: SearchAction.filters)) + .accentColor(setting.accentColor).autoBlur(radius: blurRadius) + } + .jumpPageAlert( + index: viewStore.binding(\.$jumpPageIndex), + isPresented: viewStore.binding(\.$jumpPageAlertPresented), + isFocused: viewStore.binding(\.$jumpPageAlertFocused), + pageNumber: viewStore.pageNumber, + jumpAction: { viewStore.send(.performJumpPage) } + ) + .animation(.default, value: viewStore.jumpPageAlertPresented) + .searchable(text: viewStore.binding(\.$keyword)) + .onSubmit(of: .search) { + viewStore.send(.fetchGalleries()) + } + .onAppear { + if viewStore.galleries.isEmpty { + DispatchQueue.main.async { + viewStore.send(.fetchGalleries(nil, keyword)) + } + } + } + .background(navigationLink) + .toolbar(content: toolbar) + .navigationTitle(viewStore.lastKeyword) + } + + @ViewBuilder private var navigationLink: some View { + if DeviceUtil.isPhone { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchState.Route.detail) { route in + DetailView( + store: store.scope(state: \.detailState, action: SearchAction.detail), + gid: route.wrappedValue, user: user, setting: $setting, + blurRadius: blurRadius, tagTranslator: tagTranslator + ) + } + } + } + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(disabled: viewStore.jumpPageAlertPresented) { + ToolbarFeaturesMenu { + FiltersButton { + viewStore.send(.setNavigation(.filters)) + } + QuickSearchButton { + viewStore.send(.setNavigation(.quickSearch)) + } + JumpPageButton(pageNumber: viewStore.pageNumber) { + viewStore.send(.presentJumpPageAlert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + viewStore.send(.setJumpPageAlertFocused(true)) + } + } + } + } + } +} + +struct SearchView_Previews: PreviewProvider { + static var previews: some View { + SearchView( + store: .init( + initialState: .init(), + reducer: searchReducer, + environment: SearchEnvironment( + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + hapticClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + uiApplicationClient: .live + ) + ), + keyword: .init(), + user: .init(), + setting: .constant(.init()), + blurRadius: 0, + tagTranslator: .init() + ) + } +} diff --git a/EhPanda/View/Search/Support/QuickSearchStore.swift b/EhPanda/View/Search/Support/QuickSearchStore.swift new file mode 100644 index 00000000..47d3a9fb --- /dev/null +++ b/EhPanda/View/Search/Support/QuickSearchStore.swift @@ -0,0 +1,140 @@ +// +// QuickSearchStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/20. +// + +import SwiftUI +import ComposableArchitecture + +struct QuickSearchState: Equatable { + enum Route: Equatable { + case newWord + case editWord + case deleteWord(QuickSearchWord) + } + enum FocusField { + case name + case content + } + struct CancelID: Hashable { + let id = String(describing: QuickSearchState.self) + } + + @BindableState var route: Route? + @BindableState var focusedField: FocusField? + @BindableState var editingWord: QuickSearchWord = .empty + @BindableState var listEditMode: EditMode = .inactive + var isListEditing: Bool { + get { listEditMode == .active } + set { listEditMode = newValue ? .active : .inactive } + } + + var loadingState: LoadingState = .idle + var quickSearchWords = [QuickSearchWord]() +} + +enum QuickSearchAction: BindableAction { + case binding(BindingAction) + case setNavigation(QuickSearchState.Route?) + case clearSubStates + + case syncQuickSearchWords + + case toggleListEditing + case onTextFieldSubmitted + case setEditingWord(QuickSearchWord) + + case appendWord + case editWord + case deleteWord(QuickSearchWord) + case deleteWordWithOffsets(IndexSet) + case moveWord(IndexSet, Int) + + case teardown + case fetchQuickSearchWords + case fetchQuickSearchWordsDone([QuickSearchWord]) +} + +struct QuickSearchEnvironment { + let databaseClient: DatabaseClient +} + +let quickSearchReducer = Reducer +{ state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.focusedField = nil + state.editingWord = .empty + return .none + + case .syncQuickSearchWords: + return environment.databaseClient.updateQuickSearchWords(state.quickSearchWords).fireAndForget() + + case .toggleListEditing: + state.isListEditing.toggle() + return .none + + case .onTextFieldSubmitted: + switch state.focusedField { + case .name: + state.focusedField = .content + default: + state.focusedField = nil + } + return .none + + case .setEditingWord(let word): + state.editingWord = word + return .none + + case .appendWord: + state.quickSearchWords.append(state.editingWord) + return .init(value: .syncQuickSearchWords) + + case .editWord: + if let index = state.quickSearchWords.firstIndex(where: { $0.id == state.editingWord.id }) { + state.quickSearchWords[index] = state.editingWord + return .init(value: .syncQuickSearchWords) + } + return .none + + case .deleteWord(let word): + state.quickSearchWords = state.quickSearchWords.filter({ $0 != word }) + return .init(value: .syncQuickSearchWords) + + case .deleteWordWithOffsets(let offsets): + state.quickSearchWords.remove(atOffsets: offsets) + return .init(value: .syncQuickSearchWords) + + case .moveWord(let source, let destination): + state.quickSearchWords.move(fromOffsets: source, toOffset: destination) + return .init(value: .syncQuickSearchWords) + + case .teardown: + return .cancel(id: QuickSearchState.CancelID()) + + case .fetchQuickSearchWords: + state.loadingState = .loading + return environment.databaseClient + .fetchQuickSearchWords().map(QuickSearchAction.fetchQuickSearchWordsDone) + .cancellable(id: QuickSearchState.CancelID()) + + case .fetchQuickSearchWordsDone(let words): + state.loadingState = .idle + state.quickSearchWords = words + return .none + } +} +.binding() diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift new file mode 100644 index 00000000..f2389746 --- /dev/null +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -0,0 +1,204 @@ +// +// QuickSearchView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/09/25. +// + +import SwiftUI +import ComposableArchitecture + +struct QuickSearchView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let searchAction: (String) -> Void + + @FocusState private var focusedField: QuickSearchState.FocusField? + + init(store: Store, searchAction: @escaping (String) -> Void) { + self.store = store + viewStore = ViewStore(store) + self.searchAction = searchAction + } + + var body: some View { + NavigationView { + ZStack { + List { + ForEach(viewStore.quickSearchWords) { word in + Button { + searchAction(word.content) + } label: { + VStack(alignment: .leading, spacing: 5) { + if !word.name.isEmpty { + Text(word.name).font(.subheadline).foregroundColor(.secondary).lineLimit(1) + } + Text(word.content).fontWeight(.medium).font(.title3).lineLimit(2) + } + .tint(.primary) + } + .swipeActions(edge: .trailing) { + Button { + viewStore.send(.setNavigation(.deleteWord(word))) + } label: { + Image(systemSymbol: .trash) + } + .tint(.red) + Button { + viewStore.send(.setEditingWord(word)) + viewStore.send(.setNavigation(.editWord)) + } label: { + Image(systemSymbol: .squareAndPencil) + } + } + .withArrow(isVisible: !viewStore.isListEditing).padding(5) + .confirmationDialog( + message: R.string.localizable.confirmationDialogTitleDelete(), + unwrapping: viewStore.binding(\.$route), + case: /QuickSearchState.Route.deleteWord, + matching: word + ) { route in + Button(R.string.localizable.confirmationDialogButtonDelete(), role: .destructive) { + viewStore.send(.deleteWord(route)) + } + } + } + .onDelete { offsets in + viewStore.send(.deleteWordWithOffsets(offsets)) + } + .onMove { source, destination in + viewStore.send(.moveWord(source, destination)) + } + } + LoadingView().opacity( + viewStore.loadingState == .loading + && viewStore.quickSearchWords.isEmpty ? 1 : 0 + ) + ErrorView(error: .notFound) + .opacity( + viewStore.loadingState != .loading + && viewStore.quickSearchWords.isEmpty ? 1 : 0 + ) + } + .synchronize(viewStore.binding(\.$focusedField), $focusedField) + .environment(\.editMode, viewStore.binding(\.$listEditMode)) + .animation(.default, value: viewStore.quickSearchWords) + .animation(.default, value: viewStore.listEditMode) + .onAppear { + if viewStore.quickSearchWords.isEmpty { + viewStore.send(.fetchQuickSearchWords) + } + } + .toolbar(content: toolbar) + .background(navigationLinks) + .navigationTitle(R.string.localizable.quickSearchViewTitleQuickSearch()) + } + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Button { + viewStore.send(.setEditingWord(.empty)) + viewStore.send(.setNavigation(.newWord)) + } label: { + Image(systemSymbol: .plus) + } + Button { + viewStore.send(.toggleListEditing) + } label: { + Image(systemSymbol: .pencilCircle) + .symbolVariant(viewStore.isListEditing ? .fill : .none) + } + } + } + @ViewBuilder private var navigationLinks: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /QuickSearchState.Route.newWord) { _ in + EditWordView( + title: R.string.localizable.quickSearchViewTitleNewWord(), + word: viewStore.binding(\.$editingWord), + focusedField: $focusedField, + submitAction: { viewStore.send(.onTextFieldSubmitted) }, + confirmAction: { + viewStore.send(.appendWord) + viewStore.send(.setNavigation(nil)) + } + ) + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /QuickSearchState.Route.editWord) { _ in + EditWordView( + title: R.string.localizable.quickSearchViewTitleEditWord(), + word: viewStore.binding(\.$editingWord), + focusedField: $focusedField, + submitAction: { viewStore.send(.onTextFieldSubmitted) }, + confirmAction: { + viewStore.send(.editWord) + viewStore.send(.setNavigation(nil)) + } + ) + } + } +} + +extension QuickSearchView { + // MARK: EditWordView + struct EditWordView: View { + private let title: String + @Binding private var word: QuickSearchWord + private let focusedField: FocusState.Binding + private let submitAction: () -> Void + private let confirmAction: () -> Void + + init( + title: String, word: Binding, + focusedField: FocusState.Binding, + submitAction: @escaping () -> Void, confirmAction: @escaping () -> Void + ) { + self.title = title + _word = word + self.focusedField = focusedField + self.submitAction = submitAction + self.confirmAction = confirmAction + } + + var body: some View { + Form { + Section(R.string.localizable.quickSearchViewTitleName()) { + TextField(R.string.localizable.quickSearchViewPlaceholderOptional(), text: $word.name) + .focused(focusedField, equals: .name) + } + Section(R.string.localizable.quickSearchViewTitleContent()) { + TextEditor(text: $word.content) + .disableAutocorrection(true) + .textInputAutocapitalization(.never) + .focused(focusedField, equals: .content) + } + } + .toolbar(content: toolbar) + .onSubmit(of: .text, submitAction) + .navigationTitle(title) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Button(action: confirmAction) { + Text(R.string.localizable.quickSearchViewToolbarItemButtonConfirm()).bold() + } + } + } + } +} + +struct QuickSearchView_Previews: PreviewProvider { + static var previews: some View { + QuickSearchView( + store: .init( + initialState: .init(), + reducer: quickSearchReducer, + environment: QuickSearchEnvironment( + databaseClient: .live + ) + ), + searchAction: { _ in } + ) + } +} diff --git a/EhPanda/View/Setting/AccountSettingView.swift b/EhPanda/View/Setting/AccountSettingView.swift index b0a1da59..99826515 100644 --- a/EhPanda/View/Setting/AccountSettingView.swift +++ b/EhPanda/View/Setting/AccountSettingView.swift @@ -6,193 +6,233 @@ // import SwiftUI -import Kingfisher -import TTProgressHUD +import ComposableArchitecture -struct AccountSettingView: View, StoreAccessor { - @AppStorage(wrappedValue: .ehentai, AppUserDefaults.galleryHost.rawValue) - var galleryHost: GalleryHost - @EnvironmentObject var store: Store - @State private var logoutDialogPresented = false +struct AccountSettingView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + @Binding private var galleryHost: GalleryHost + @Binding private var showsNewDawnGreeting: Bool + private let bypassesSNIFiltering: Bool + private let blurRadius: Double - @State private var hudVisible = false - @State private var hudConfig = TTProgressHUDConfig() - - private let ehURL = Defaults.URL.ehentai.safeURL() - private let exURL = Defaults.URL.exhentai.safeURL() - private let igneousKey = Defaults.Cookie.igneous - private let memberIDKey = Defaults.Cookie.ipbMemberId - private let passHashKey = Defaults.Cookie.ipbPassHash + init( + store: Store, + galleryHost: Binding, showsNewDawnGreeting: Binding, + bypassesSNIFiltering: Bool, blurRadius: Double + ) { + self.store = store + viewStore = ViewStore(store) + _galleryHost = galleryHost + _showsNewDawnGreeting = showsNewDawnGreeting + self.bypassesSNIFiltering = bypassesSNIFiltering + self.blurRadius = blurRadius + } // MARK: AccountSettingView var body: some View { - ZStack { - Form { - Section { - Picker("Gallery", selection: $galleryHost) { - ForEach(GalleryHost.allCases) { - Text($0.rawValue.localized).tag($0) - } + Form { + Section { + Picker("", selection: $galleryHost) { + ForEach(GalleryHost.allCases) { + Text($0.rawValue).tag($0) } - .pickerStyle(.segmented) - if !AuthorizationUtil.didLogin { - NavigationLink("Login", destination: LoginView()).foregroundStyle(.tint) - } else { - Button("Logout", role: .destructive) { - logoutDialogPresented = true - } - } - if AuthorizationUtil.didLogin { - Group { - NavigationLink("Account configuration", destination: EhSettingView()) - if !setting.bypassesSNIFiltering { - Button("Manage tags subscription") { - store.dispatch(.setSettingViewSheetState(.webviewMyTags)) - } - .withArrow() - } - Toggle( - "Show new dawn greeting", isOn: $store.appState.settings.setting.showNewDawnGreeting - ) - } - .foregroundColor(.primary) - } - } - Section("E-Hentai") { - CookieRow(key: memberIDKey, value: ehMemberID, submitAction: setEhCookieValue) - CookieRow(key: passHashKey, value: ehPassHash, submitAction: setEhCookieValue) - Button("Copy cookies", action: copyEhCookies).foregroundStyle(.tint).font(.subheadline) - } - Section("ExHentai") { - CookieRow(key: igneousKey, value: igneous, submitAction: setExCookieValue) - CookieRow(key: memberIDKey, value: exMemberID, submitAction: setExCookieValue) - CookieRow(key: passHashKey, value: exPassHash, submitAction: setExCookieValue) - Button("Copy cookies", action: copyExCookies).foregroundStyle(.tint).font(.subheadline) } + .pickerStyle(.segmented) + AccountSection( + route: viewStore.binding(\.$route), + showsNewDawnGreeting: $showsNewDawnGreeting, + bypassesSNIFiltering: bypassesSNIFiltering, + loginAction: { viewStore.send(.setNavigation(.login)) }, + logoutAction: { viewStore.send(.onLogoutConfirmButtonTapped) }, + logoutDialogAction: { viewStore.send(.setNavigation(.logout)) }, + configureAccountAction: { viewStore.send(.setNavigation(.ehSetting)) }, + manageTagsAction: { viewStore.send(.setNavigation(.webView(Defaults.URL.myTags))) } + ) } - TTProgressHUD($hudVisible, config: hudConfig) + CookieSection( + ehCookiesState: viewStore.binding(\.$ehCookiesState), + exCookiesState: viewStore.binding(\.$exCookiesState), + copyAction: { viewStore.send(.copyCookies($0)) } + ) } - .confirmationDialog( - "Are you sure to logout?", isPresented: $logoutDialogPresented, titleVisibility: .visible - ) { - Button("Logout", role: .destructive, action: logout) + .progressHUD( + config: viewStore.hudConfig, + unwrapping: viewStore.binding(\.$route), + case: /AccountSettingState.Route.hud + ) + .sheet(unwrapping: viewStore.binding(\.$route), case: /AccountSettingState.Route.webView) { route in + WebView(url: route.wrappedValue) + .autoBlur(radius: blurRadius) } - .navigationBarTitle("Account") + .onAppear { viewStore.send(.loadCookies) } + .background(navigationLinks) + .navigationTitle(R.string.localizable.accountSettingViewTitleAccount()) } } +// MARK: NavigationLinks private extension AccountSettingView { - // MARK: Logout - func logout() { - CookiesUtil.clearAll() - store.dispatch(.resetUser) - PersistenceController.removeImageURLs() - KingfisherManager.shared.cache.clearDiskCache() + @ViewBuilder var navigationLinks: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AccountSettingState.Route.login) { _ in + LoginView( + store: store.scope(state: \.loginState, action: AccountSettingAction.login), + bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius + ) + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AccountSettingState.Route.ehSetting) { _ in + EhSettingView( + store: store.scope(state: \.ehSettingState, action: AccountSettingAction.ehSetting), + bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius + ) + } } +} - // MARK: Cookies - var igneous: CookieValue { - CookiesUtil.get(for: exURL, key: igneousKey) - } - var ehMemberID: CookieValue { - CookiesUtil.get(for: ehURL, key: memberIDKey) - } - var exMemberID: CookieValue { - CookiesUtil.get(for: exURL, key: memberIDKey) - } - var ehPassHash: CookieValue { - CookiesUtil.get(for: ehURL, key: passHashKey) - } - var exPassHash: CookieValue { - CookiesUtil.get(for: exURL, key: passHashKey) - } - func setEhCookieValue(key: String, value: String) { - setCookieValue(url: ehURL, key: key, value: value) - } - func setExCookieValue(key: String, value: String) { - setCookieValue(url: exURL, key: key, value: value) +// MARK: AccountSection +private struct AccountSection: View { + @Binding private var route: AccountSettingState.Route? + @Binding private var showsNewDawnGreeting: Bool + private let bypassesSNIFiltering: Bool + private let loginAction: () -> Void + private let logoutAction: () -> Void + private let logoutDialogAction: () -> Void + private let configureAccountAction: () -> Void + private let manageTagsAction: () -> Void + + init( + route: Binding, + showsNewDawnGreeting: Binding, bypassesSNIFiltering: Bool, + loginAction: @escaping () -> Void, logoutAction: @escaping () -> Void, + logoutDialogAction: @escaping () -> Void, + configureAccountAction: @escaping () -> Void, + manageTagsAction: @escaping () -> Void + ) { + _route = route + _showsNewDawnGreeting = showsNewDawnGreeting + self.bypassesSNIFiltering = bypassesSNIFiltering + self.loginAction = loginAction + self.logoutAction = logoutAction + self.logoutDialogAction = logoutDialogAction + self.configureAccountAction = configureAccountAction + self.manageTagsAction = manageTagsAction } - func setCookieValue(url: URL, key: String, value: String) { - if CookiesUtil.checkExistence(for: url, key: key) { - CookiesUtil.edit(for: url, key: key, value: value) + + var body: some View { + if !CookiesUtil.didLogin { + Button(R.string.localizable.accountSettingViewButtonLogin(), action: loginAction) } else { - CookiesUtil.set(for: url, key: key, value: value) + Button( + R.string.localizable.confirmationDialogButtonLogout(), + role: .destructive, action: logoutDialogAction + ) + .confirmationDialog( + message: R.string.localizable.confirmationDialogTitleLogout(), + unwrapping: $route, case: /AccountSettingState.Route.logout + ) { + Button( + R.string.localizable.confirmationDialogButtonLogout(), + role: .destructive, action: logoutAction + ) + } + Group { + Button( + R.string.localizable.accountSettingViewButtonAccountConfiguration(), + action: configureAccountAction + ) + .withArrow() + if !bypassesSNIFiltering { + Button( + R.string.localizable.accountSettingViewButtonTagsManagement(), + action: manageTagsAction + ) + .withArrow() + } + Toggle(R.string.localizable.accountSettingViewTitleShowsNewDawnGreeting(), isOn: $showsNewDawnGreeting) + } + .foregroundColor(.primary) } } - func copyEhCookies() { - let cookies = "\(memberIDKey): \(ehMemberID.rawValue)" - + "\n\(passHashKey): \(ehPassHash.rawValue)" - PasteboardUtil.save(value: cookies) - presentHUD() - } - func copyExCookies() { - let cookies = "\(igneousKey): \(igneous.rawValue)" - + "\n\(memberIDKey): \(exMemberID.rawValue)" - + "\n\(passHashKey): \(exPassHash.rawValue)" - PasteboardUtil.save(value: cookies) - presentHUD() +} + +// MARK: CookieSection +private struct CookieSection: View { + @Binding private var ehCookiesState: CookiesState + @Binding private var exCookiesState: CookiesState + private let copyAction: (GalleryHost) -> Void + + init( + ehCookiesState: Binding, + exCookiesState: Binding, + copyAction: @escaping (GalleryHost) -> Void + ) { + _ehCookiesState = ehCookiesState + _exCookiesState = exCookiesState + self.copyAction = copyAction } - func presentHUD() { - hudConfig = TTProgressHUDConfig( - type: .success, title: "Success".localized, - caption: "Copied to clipboard".localized, - shouldAutoHide: true, autoHideInterval: 1 - ) - hudVisible.toggle() + + var body: some View { + Section(GalleryHost.ehentai.rawValue) { + CookieRow(cookieState: $ehCookiesState.memberID) + CookieRow(cookieState: $ehCookiesState.passHash) + Button(R.string.localizable.accountSettingViewButtonCopyCookies()) { + copyAction(.ehentai) + } + .foregroundStyle(.tint).font(.subheadline) + } + Section(GalleryHost.exhentai.rawValue) { + CookieRow(cookieState: $exCookiesState.igneous) + CookieRow(cookieState: $exCookiesState.memberID) + CookieRow(cookieState: $exCookiesState.passHash) + Button(R.string.localizable.accountSettingViewButtonCopyCookies()) { + copyAction(.exhentai) + } + .foregroundStyle(.tint).font(.subheadline) + } } } // MARK: CookieRow private struct CookieRow: View { - @State private var content: String - - private let key: String - private let value: String - private let cookieValue: CookieValue - private let submitAction: (String, String) -> Void - private var notVerified: Bool { - !cookieValue.localizedString.isEmpty && !cookieValue.rawValue.isEmpty - } - - init( - key: String, value: CookieValue, - submitAction: @escaping (String, String) -> Void - ) { - _content = State(initialValue: value.rawValue) + @Binding private var cookieState: CookieState - self.key = key - self.value = value.localizedString.isEmpty - ? value.rawValue : value.localizedString - self.cookieValue = value - self.submitAction = submitAction + init(cookieState: Binding) { + _cookieState = cookieState } var body: some View { HStack { - Text(key) + Text(cookieState.key) Spacer() - ZStack { - TextField(value, text: $content) - .submitLabel(.done) - .disableAutocorrection(true) - .multilineTextAlignment(.trailing) - .textInputAutocapitalization(.none) - .onChange(of: content) { - submitAction(key, $0) - } - } - ZStack { - Image(systemName: "checkmark.circle") - .foregroundStyle(.green).opacity(notVerified ? 0 : 1) - Image(systemName: "xmark.circle") - .foregroundStyle(.red).opacity(notVerified ? 1 : 0) - } + TextField(cookieState.value.placeholder, text: $cookieState.editingText) + .submitLabel(.done).disableAutocorrection(true) + .multilineTextAlignment(.trailing) + .textInputAutocapitalization(.none) + Image(systemSymbol: cookieState.value.isInvalid ? .xmarkCircle : .checkmarkCircle) + .foregroundStyle(cookieState.value.isInvalid ? .red : .green) } } } -// MARK: Definition -struct CookieValue { - let rawValue: String - let localizedString: String +struct AccountSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + AccountSettingView( + store: .init( + initialState: .init(), + reducer: accountSettingReducer, + environment: AccountSettingEnvironment( + hapticClient: .live, + cookiesClient: .live, + clipboardClient: .live, + uiApplicationClient: .live + ) + ), + galleryHost: .constant(.ehentai), + showsNewDawnGreeting: .constant(false), + bypassesSNIFiltering: false, + blurRadius: 0 + ) + } + } } diff --git a/EhPanda/View/Setting/AppearanceSettingView.swift b/EhPanda/View/Setting/AppearanceSettingView.swift index afc8326a..c8030e80 100644 --- a/EhPanda/View/Setting/AppearanceSettingView.swift +++ b/EhPanda/View/Setting/AppearanceSettingView.swift @@ -6,189 +6,196 @@ // import SwiftUI +import ComposableArchitecture -struct AppearanceSettingView: View, StoreAccessor { - @EnvironmentObject var store: Store - @State private var isNavLinkActive = false +struct AppearanceSettingView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + @Binding private var preferredColorScheme: PreferredColorScheme + @Binding private var accentColor: Color + @Binding private var appIconType: AppIconType + @Binding private var listDisplayMode: ListDisplayMode + @Binding private var showsTagsInList: Bool + @Binding private var listTagsNumberMaximum: Int - private var settingBinding: Binding { - $store.appState.settings.setting - } - private var selectedIcon: IconType { - store.appState.settings.setting.appIconType - } - private var iconType: IconType { - var alterName: String? - AppUtil.dispatchMainSync { - alterName = UIApplication.shared.alternateIconName - } - - guard let iconName = alterName, - let selection = IconType.allCases.filter({ - iconName.contains($0.fileName ?? "") - }).first else { return .default } - return selection + init( + store: Store, + preferredColorScheme: Binding, accentColor: Binding, + appIconType: Binding, listDisplayMode: Binding, + showsTagsInList: Binding, listTagsNumberMaximum: Binding + ) { + self.store = store + viewStore = ViewStore(store) + _preferredColorScheme = preferredColorScheme + _accentColor = accentColor + _appIconType = appIconType + _listDisplayMode = listDisplayMode + _showsTagsInList = showsTagsInList + _listTagsNumberMaximum = listTagsNumberMaximum } var body: some View { Form { Section { HStack { - Text("Theme") + Text(R.string.localizable.appearanceSettingViewTitleTheme()) Spacer() Picker( - selection: settingBinding.preferredColorScheme, - label: Text(setting.preferredColorScheme.rawValue.localized), + selection: $preferredColorScheme, + label: Text(preferredColorScheme.value), content: { ForEach(PreferredColorScheme.allCases) { colorScheme in - Text(colorScheme.rawValue.localized).tag(colorScheme) + Text(colorScheme.value).tag(colorScheme) } } ) } .pickerStyle(.menu) - ColorPicker("Tint Color", selection: settingBinding.accentColor) - Button("App Icon") { - isNavLinkActive.toggle() + ColorPicker(R.string.localizable.appearanceSettingViewTitleTintColor(), selection: $accentColor) + Button(R.string.localizable.appearanceSettingViewButtonAppIcon()) { + viewStore.send(.setNavigation(.appIcon)) } .foregroundStyle(.primary).withArrow() } - Section("List".localized) { + Section(R.string.localizable.appearanceSettingViewSectionTitleList()) { HStack { - Text("Display mode") + Text(R.string.localizable.appearanceSettingViewTitleDisplayMode()) Spacer() Picker( - selection: settingBinding.listMode, - label: Text(setting.listMode.rawValue.localized), + selection: $listDisplayMode, + label: Text(listDisplayMode.value), content: { - ForEach(ListMode.allCases) { listMode in - Text(listMode.rawValue.localized).tag(listMode) + ForEach(ListDisplayMode.allCases) { listMode in + Text(listMode.value).tag(listMode) } } ) } .pickerStyle(.menu) - Toggle(isOn: settingBinding.showsSummaryRowTags) { - Text("Shows tags in list") + Toggle(isOn: $showsTagsInList) { + Text(R.string.localizable.appearanceSettingViewTitleShowsTagsInList()) } HStack { - Text("Maximum number of tags") + Text(R.string.localizable.appearanceSettingViewTitleMaximumNumberOfTags()) Spacer() Picker( - selection: settingBinding.summaryRowTagsMaximum, - label: Text("\(setting.summaryRowTagsMaximum)") + selection: $listTagsNumberMaximum, + label: Text("\(listTagsNumberMaximum)") ) { - Text("Infinity").tag(0) + Text(R.string.localizable.appearanceSettingViewMenuTitleInfite()).tag(0) ForEach(Array(stride(from: 5, through: 20, by: 5)), id: \.self) { num in Text("\(num)").tag(num) } } .pickerStyle(.menu) } - .disabled(!setting.showsSummaryRowTags) + .disabled(!showsTagsInList) } } - .background { - NavigationLink( - destination: SelectAppIconView(selectedIcon: selectedIcon) { - store.dispatch(.setAppIconType(iconType)) - }, - isActive: $isNavLinkActive, label: {} - ) + .background(navigationLink) + .navigationTitle(R.string.localizable.appearanceSettingViewTitleAppearance()) + } + private var navigationLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AppearanceSettingState.Route.appIcon) { _ in + AppIconView(appIconType: $appIconType) } - .navigationBarTitle("Appearance") } } // MARK: SelectAppIconView -private struct SelectAppIconView: View { - private let selectedIcon: IconType - private let selectAction: () -> Void +private struct AppIconView: View { + @Binding private var appIconType: AppIconType - init(selectedIcon: IconType, selectAction: @escaping () -> Void) { - self.selectedIcon = selectedIcon - self.selectAction = selectAction + init(appIconType: Binding) { + _appIconType = appIconType } var body: some View { Form { Section { - ForEach(IconType.allCases) { icon in + ForEach(AppIconType.allCases) { icon in AppIconRow( - iconName: icon.iconName, - iconDesc: icon.rawValue, - isSelected: icon == selectedIcon + iconName: icon.name, + filename: icon.filename, + isSelected: icon == appIconType ) .contentShape(Rectangle()) - .onTapGesture { setIcon(icon) } + .onTapGesture { appIconType = icon } } } } - .onAppear(perform: selectAction) - } - - private func setIcon(_ icon: IconType) { - UIApplication.shared.setAlternateIconName(icon.fileName) { error in - if let error = error { - HapticUtil.generateNotificationFeedback(style: .error) - Logger.error(error) - } - selectAction() - } + .navigationTitle(R.string.localizable.appIconViewTitleAppIcon()) } } // MARK: AppIconRow private struct AppIconRow: View { private let iconName: String - private let iconDesc: String + private let filename: String private let isSelected: Bool - init(iconName: String, iconDesc: String, isSelected: Bool) { + init(iconName: String, filename: String, isSelected: Bool) { self.iconName = iconName - self.iconDesc = iconDesc + self.filename = filename self.isSelected = isSelected } var body: some View { HStack { - Image(iconName).resizable().scaledToFit() - .frame(width: 60, height: 60).cornerRadius(12) + Image(uiImage: .init(named: filename, in: .main, with: nil) ?? .init()) + .resizable().scaledToFit().frame(width: 60, height: 60).cornerRadius(12) .padding(.vertical, 10).padding(.trailing, 20) - Text(iconDesc.localized) + Text(iconName) Spacer() - Image(systemName: "checkmark.circle.fill") + Image(systemSymbol: .checkmarkCircleFill) .opacity(isSelected ? 1 : 0).foregroundStyle(.tint).imageScale(.large) } } } // MARK: Definition -enum IconType: String, Codable, Identifiable, CaseIterable { - var id: Int { hashValue } +enum AppIconType: Int, Codable, Identifiable, CaseIterable { + var id: Int { rawValue } - case normal = "Normal" - case `default` = "Default" - case weird = "Weird" + case `default` + case ukiyoe } -extension IconType { - var iconName: String { +extension AppIconType { + var name: String { switch self { - case .normal: - return "AppIcon_Normal" case .default: - return "AppIcon_Default" - case .weird: - return "AppIcon_Weird" + return R.string.localizable.enumAppIconTypeValueDefault() + case .ukiyoe: + return R.string.localizable.enumAppIconTypeValueUkiyoe() } } - var fileName: String? { + var filename: String { switch self { case .default: - return nil - default: - return rawValue + return "AppIcon_Default" + case .ukiyoe: + return "AppIcon_Ukiyoe" + } + } +} + +struct AppearanceSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + AppearanceSettingView( + store: .init( + initialState: .init(), + reducer: appearanceSettingReducer, + environment: AppearanceSettingEnvironment() + ), + preferredColorScheme: .constant(.automatic), + accentColor: .constant(.blue), + appIconType: .constant(.default), + listDisplayMode: .constant(.detail), + showsTagsInList: .constant(false), + listTagsNumberMaximum: .constant(0) + ) } } } diff --git a/EhPanda/View/Setting/DataFlow/AccountSettingStore.swift b/EhPanda/View/Setting/DataFlow/AccountSettingStore.swift new file mode 100644 index 00000000..74f8911d --- /dev/null +++ b/EhPanda/View/Setting/DataFlow/AccountSettingStore.swift @@ -0,0 +1,172 @@ +// +// AccountSettingStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/31. +// + +import TTProgressHUD +import ComposableArchitecture + +struct CookieValue: Equatable { + let rawValue: String + let localizedString: String + + var isInvalid: Bool { + !localizedString.isEmpty && !rawValue.isEmpty + } + var placeholder: String { + localizedString.isEmpty ? rawValue : localizedString + } +} + +struct CookiesState: Equatable { + static func empty(_ host: GalleryHost) -> Self { + .init( + host: host, + igneous: .empty, + memberID: .empty, + passHash: .empty + ) + } + var allCases: [CookieState] {[ + igneous, memberID, passHash + ]} + + let host: GalleryHost + var igneous: CookieState + var memberID: CookieState + var passHash: CookieState +} + +struct CookieState: Equatable { + static let empty: Self = .init( + key: "", value: .init( + rawValue: "", localizedString: "" + ) + ) + + let key: String + var value: CookieValue + var editingText = "" +} + +struct AccountSettingState: Equatable { + enum Route: Equatable { + case hud + case login + case logout + case ehSetting + case webView(URL) + } + + @BindableState var route: Route? + @BindableState var ehCookiesState: CookiesState = .empty(.ehentai) + @BindableState var exCookiesState: CookiesState = .empty(.exhentai) + var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded + + var loginState = LoginState() + var ehSettingState = EhSettingState() +} + +enum AccountSettingAction: BindableAction { + case binding(BindingAction) + case setNavigation(AccountSettingState.Route?) + case onLogoutConfirmButtonTapped + case clearSubStates + + case loadCookies + case copyCookies(GalleryHost) + + case login(LoginAction) + case ehSetting(EhSettingAction) +} + +struct AccountSettingEnvironment { + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let clipboardClient: ClipboardClient + let uiApplicationClient: UIApplicationClient +} + +let accountSettingReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$ehCookiesState): + return environment.cookiesClient.setCookies(state: state.ehCookiesState).fireAndForget() + + case .binding(\.$exCookiesState): + return environment.cookiesClient.setCookies(state: state.exCookiesState).fireAndForget() + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .onLogoutConfirmButtonTapped: + return .init(value: .loadCookies) + + case .clearSubStates: + state.loginState = .init() + state.ehSettingState = .init() + return .merge( + .init(value: .login(.teardown)), + .init(value: .ehSetting(.teardown)) + ) + + case .loadCookies: + state.ehCookiesState = environment.cookiesClient.loadCookiesState(host: .ehentai) + state.exCookiesState = environment.cookiesClient.loadCookiesState(host: .exhentai) + return .none + + case .copyCookies(let host): + let cookiesDescription = environment.cookiesClient.getCookiesDescription(host: host) + return .merge( + .init(value: .setNavigation(.hud)), + environment.clipboardClient.saveText(cookiesDescription).fireAndForget(), + environment.hapticClient.generateNotificationFeedback(.success).fireAndForget() + ) + + case .login(.loginDone): + return environment.cookiesClient.didLogin ? .init(value: .setNavigation(nil)) : .none + + case .login: + return .none + + case .ehSetting: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /AccountSettingState.Route.webView, + hapticClient: \.hapticClient + ) + .binding(), + loginReducer.pullback( + state: \.loginState, + action: /AccountSettingAction.login, + environment: { + .init( + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient + ) + } + ), + ehSettingReducer.pullback( + state: \.ehSettingState, + action: /AccountSettingAction.ehSetting, + environment: { + .init( + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Setting/DataFlow/AppearanceSettingStore.swift b/EhPanda/View/Setting/DataFlow/AppearanceSettingStore.swift new file mode 100644 index 00000000..68f47a50 --- /dev/null +++ b/EhPanda/View/Setting/DataFlow/AppearanceSettingStore.swift @@ -0,0 +1,36 @@ +// +// AppearanceSettingStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/03. +// + +import ComposableArchitecture + +struct AppearanceSettingState: Equatable { + enum Route { + case appIcon + } + + @BindableState var route: Route? +} + +enum AppearanceSettingAction: BindableAction { + case binding(BindingAction) + case setNavigation(AppearanceSettingState.Route?) +} + +struct AppearanceSettingEnvironment {} + +let appearanceSettingReducer = Reducer +{ state, action, _ in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + } +} +.binding() diff --git a/EhPanda/View/Setting/DataFlow/EhSettingStore.swift b/EhPanda/View/Setting/DataFlow/EhSettingStore.swift new file mode 100644 index 00000000..f118250f --- /dev/null +++ b/EhPanda/View/Setting/DataFlow/EhSettingStore.swift @@ -0,0 +1,137 @@ +// +// EhSettingStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/01. +// + +import ComposableArchitecture + +struct EhSettingState: Equatable { + enum Route: Equatable { + case webView(URL) + case deleteProfile + } + struct CancelID: Hashable { + let id = String(describing: EhSettingState.self) + } + + @BindableState var route: Route? + @BindableState var editingProfileName = "" + @BindableState var ehSetting: EhSetting? + @BindableState var ehProfile: EhProfile? + var loadingState: LoadingState = .idle + var submittingState: LoadingState = .idle + + mutating func setEhSetting(_ ehSetting: EhSetting) { + let ehProfile: EhProfile = ehSetting.ehProfiles + .filter(\.isSelected).first.forceUnwrapped + self.ehSetting = ehSetting + self.ehProfile = ehProfile + editingProfileName = ehProfile.name + } +} + +enum EhSettingAction: BindableAction { + case binding(BindingAction) + case setNavigation(EhSettingState.Route?) + case setKeyboardHidden + case setDefaultProfile(Int) + + case teardown + case fetchEhSetting + case fetchEhSettingDone(Result) + case submitChanges + case submitChangesDone(Result) + case performAction(EhProfileAction?, String?, Int) + case performActionDone(Result) +} + +struct EhSettingEnvironment { + let hapticClient: HapticClient + let cookiesClient: CookiesClient + let uiApplicationClient: UIApplicationClient +} + +let ehSettingReducer = Reducer { state, action, environment in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .setKeyboardHidden: + return environment.uiApplicationClient.hideKeyboard().fireAndForget() + + case .setDefaultProfile(let profileSet): + return environment.cookiesClient.setOrEditCookie( + for: Defaults.URL.host, key: Defaults.Cookie.selectedProfile, value: String(profileSet) + ) + .fireAndForget() + + case .teardown: + return .cancel(id: EhSettingState.CancelID()) + + case .fetchEhSetting: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return EhSettingRequest().effect.map(EhSettingAction.fetchEhSettingDone) + .cancellable(id: EhSettingState.CancelID()) + + case .fetchEhSettingDone(let result): + state.loadingState = .idle + + switch result { + case .success(let ehSetting): + state.setEhSetting(ehSetting) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .submitChanges: + guard state.submittingState != .loading, + let ehSetting = state.ehSetting + else { return .none } + + state.submittingState = .loading + return SubmitEhSettingChangesRequest(ehSetting: ehSetting) + .effect.map(EhSettingAction.submitChangesDone).cancellable(id: EhSettingState.CancelID()) + + case .submitChangesDone(let result): + state.submittingState = .idle + + switch result { + case .success(let ehSetting): + state.setEhSetting(ehSetting) + case .failure(let error): + state.submittingState = .failed(error) + } + return .none + + case .performAction(let action, let name, let set): + guard state.submittingState != .loading else { return .none } + state.submittingState = .loading + return EhProfileRequest(action: action, name: name, set: set) + .effect.map(EhSettingAction.performActionDone).cancellable(id: EhSettingState.CancelID()) + + case .performActionDone(let result): + state.submittingState = .idle + + switch result { + case .success(let ehSetting): + state.setEhSetting(ehSetting) + case .failure(let error): + state.submittingState = .failed(error) + } + return .none + } +} +.haptics( + unwrapping: \.route, + case: /EhSettingState.Route.webView, + hapticClient: \.hapticClient +) +.binding() diff --git a/EhPanda/View/Setting/DataFlow/GeneralSettingStore.swift b/EhPanda/View/Setting/DataFlow/GeneralSettingStore.swift new file mode 100644 index 00000000..9cf94380 --- /dev/null +++ b/EhPanda/View/Setting/DataFlow/GeneralSettingStore.swift @@ -0,0 +1,120 @@ +// +// GeneralSettingStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/03. +// + +import Kingfisher +import LocalAuthentication +import ComposableArchitecture + +struct GeneralSettingState: Equatable { + enum Route { + case logs + case clearCache + case removeCustomTranslations + } + + @BindableState var route: Route? + + var loadingState: LoadingState = .idle + var diskImageCacheSize = "0 KB" + var passcodeNotSet = false + + var logsState = LogsState() +} + +enum GeneralSettingAction: BindableAction { + case binding(BindingAction) + case setNavigation(GeneralSettingState.Route?) + case clearSubStates + case onTranslationsFilePicked(URL) + case onRemoveCustomTranslations + + case clearWebImageCache + case checkPasscodeSetting + case navigateToSystemSetting + case calculateWebImageDiskCache + case calculateWebImageDiskCacheDone(Result) + + case logs(LogsAction) +} + +struct GeneralSettingEnvironment { + let fileClient: FileClient + let loggerClient: LoggerClient + let libraryClient: LibraryClient + let databaseClient: DatabaseClient + let uiApplicationClient: UIApplicationClient + let authorizationClient: AuthorizationClient +} + +let generalSettingReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.logsState = .init() + return .init(value: .logs(.teardown)) + + case .onTranslationsFilePicked: + return .none + + case .onRemoveCustomTranslations: + return .none + + case .clearWebImageCache: + return .merge( + environment.libraryClient.clearWebImageDiskCache().fireAndForget(), + environment.databaseClient.removeImageURLs().fireAndForget(), + .init(value: .calculateWebImageDiskCache) + ) + + case .checkPasscodeSetting: + state.passcodeNotSet = environment.authorizationClient.passcodeNotSet() + return .none + + case .navigateToSystemSetting: + return environment.uiApplicationClient.openSettings().fireAndForget() + + case .calculateWebImageDiskCache: + return environment.libraryClient.calculateWebImageDiskCacheSize() + .map(GeneralSettingAction.calculateWebImageDiskCacheDone) + + case .calculateWebImageDiskCacheDone(let result): + switch result { + case .success(let bytes): + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + state.diskImageCacheSize = formatter.string(fromByteCount: Int64(bytes)) + case .failure(let error): + return environment.loggerClient.error(error, nil).fireAndForget() + } + return .none + + case .logs: + return .none + } + } + .binding(), + logsReducer.pullback( + state: \.logsState, + action: /GeneralSettingAction.logs, + environment: { + .init( + fileClient: $0.fileClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ) +) diff --git a/EhPanda/View/Setting/DataFlow/LoginStore.swift b/EhPanda/View/Setting/DataFlow/LoginStore.swift new file mode 100644 index 00000000..c982a0ff --- /dev/null +++ b/EhPanda/View/Setting/DataFlow/LoginStore.swift @@ -0,0 +1,113 @@ +// +// LoginStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/01. +// + +import SwiftUI +import ComposableArchitecture + +struct LoginState: Equatable { + enum Route: Equatable { + case webView(URL) + } + enum FocusedField { + case username + case password + } + struct CancelID: Hashable { + let id = String(describing: LoginState.self) + } + + @BindableState var route: Route? + @BindableState var focusedField: FocusedField? + @BindableState var username = "" + @BindableState var password = "" + var loginState: LoadingState = .idle + + var loginButtonDisabled: Bool { + username.isEmpty || password.isEmpty + } + var loginButtonColor: Color { + loginState == .loading ? .clear : loginButtonDisabled + ? .primary.opacity(0.25) : .primary.opacity(0.75) + } +} + +enum LoginAction: BindableAction { + case binding(BindingAction) + case setNavigation(LoginState.Route?) + case onTextFieldSubmitted + + case teardown + case login + case loginDone(Result) +} + +struct LoginEnvironment { + let hapticClient: HapticClient + let cookiesClient: CookiesClient +} + +let loginReducer = Reducer { state, action, environment in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .onTextFieldSubmitted: + switch state.focusedField { + case .username: + state.focusedField = .password + case .password: + state.focusedField = nil + return .init(value: .login) + default: + break + } + return .none + + case .teardown: + return .cancel(id: LoginState.CancelID()) + + case .login: + guard !state.loginButtonDisabled + || state.loginState == .loading + else { return .none } + + withAnimation { + state.loginState = .loading + } + + return .merge( + environment.hapticClient.generateFeedback(.soft).fireAndForget(), + LoginRequest(username: state.username, password: state.password) + .effect.map(LoginAction.loginDone).cancellable(id: LoginState.CancelID()) + ) + + case .loginDone(let result): + state.route = nil + var effects = [Effect]() + if environment.cookiesClient.didLogin { + state.loginState = .idle + effects.append(environment.hapticClient.generateNotificationFeedback(.success).fireAndForget()) + } else { + state.loginState = .failed(.unknown) + effects.append(environment.hapticClient.generateNotificationFeedback(.error).fireAndForget()) + } + if case .success(let response) = result, let response = response { + effects.append(environment.cookiesClient.setCredentials(response: response).fireAndForget()) + } + return .merge(effects) + } +} +.haptics( + unwrapping: \.route, + case: /LoginState.Route.webView, + hapticClient: \.hapticClient +) +.binding() diff --git a/EhPanda/View/Setting/DataFlow/LogsStore.swift b/EhPanda/View/Setting/DataFlow/LogsStore.swift new file mode 100644 index 00000000..05e2aa0b --- /dev/null +++ b/EhPanda/View/Setting/DataFlow/LogsStore.swift @@ -0,0 +1,80 @@ +// +// LogsStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/03. +// + +import ComposableArchitecture + +struct LogsState: Equatable { + enum Route: Equatable { + case log(Log) + } + struct CancelID: Hashable { + let id = String(describing: LogsState.self) + } + + @BindableState var route: Route? + var loadingState: LoadingState = .idle + var logs = [Log]() +} + +enum LogsAction: BindableAction { + case binding(BindingAction) + case setNavigation(LogsState.Route?) + case navigateToFileApp + + case teardown + case fetchLogs + case fetchLogsDone(Result<[Log], AppError>) + case deleteLog(String) + case deleteLogDone(Result) +} + +struct LogsEnvironment { + let fileClient: FileClient + let uiApplicationClient: UIApplicationClient +} + +let logsReducer = Reducer { state, action, environment in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .navigateToFileApp: + return environment.uiApplicationClient.openFileApp().fireAndForget() + + case .teardown: + return .cancel(id: LogsState.CancelID()) + + case .fetchLogs: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return environment.fileClient.fetchLogs().map(LogsAction.fetchLogsDone).cancellable(id: LogsState.CancelID()) + + case .fetchLogsDone(let result): + switch result { + case .success(let logs): + state.logs = logs + state.loadingState = .idle + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .deleteLog(let fileName): + return environment.fileClient.deleteLog(fileName).map(LogsAction.deleteLogDone) + + case .deleteLogDone(let result): + if case .success(let fileName) = result { + state.logs = state.logs.filter({ $0.fileName != fileName }) + } + return .none + } +} +.binding() diff --git a/EhPanda/View/Setting/DataFlow/SettingStore.swift b/EhPanda/View/Setting/DataFlow/SettingStore.swift new file mode 100644 index 00000000..132f5532 --- /dev/null +++ b/EhPanda/View/Setting/DataFlow/SettingStore.swift @@ -0,0 +1,470 @@ +// +// SettingStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/31. +// + +import ComposableArchitecture + +struct SettingState: Equatable { + enum Route: Int, Hashable, Identifiable, CaseIterable { + var id: Int { rawValue } + + case account + case general + case appearance + case reading + case laboratory + case ehpanda + } + + // AppEnvStorage + @BindableState var setting = Setting() + var tagTranslator = TagTranslator() + var user = User() + + @BindableState var route: Route? + var tagTranslatorLoadingState: LoadingState = .idle + + var accountSettingState = AccountSettingState() + var generalSettingState = GeneralSettingState() + var appearanceSettingState = AppearanceSettingState() + + mutating func setGreeting(_ greeting: Greeting) { + guard let currDate = greeting.updateTime else { return } + + if let prevGreeting = user.greeting, + let prevDate = prevGreeting.updateTime, + prevDate < currDate + { + user.greeting = greeting + } else if user.greeting == nil { + user.greeting = greeting + } + } + mutating func updateUser(_ user: User) { + if let displayName = user.displayName { + self.user.displayName = displayName + } + if let avatarURL = user.avatarURL { + self.user.avatarURL = avatarURL + } + if let galleryPoints = user.galleryPoints, + let credits = user.credits + { + self.user.galleryPoints = galleryPoints + self.user.credits = credits + } + } +} + +enum SettingAction: BindableAction { + case binding(BindingAction) + case setNavigation(SettingState.Route?) + case clearSubStates + + case syncAppIconType + case syncUserInterfaceStyle + case syncSetting + case syncTagTranslator + case syncUser + + case loadUserSettings + case loadUserSettingsDone(AppEnv) + case createDefaultEhProfile + case fetchIgneous + case fetchIgneousDone(Result) + case fetchUserInfo + case fetchUserInfoDone(Result) + case fetchGreeting + case fetchGreetingDone(Result) + case fetchTagTranslator + case fetchTagTranslatorDone(Result) + case fetchEhProfileIndex + case fetchEhProfileIndexDone(Result<(Int?, Bool), AppError>) + case fetchFavoriteCategories + case fetchFavoriteCategoriesDone(Result<[Int: String], AppError>) + + case account(AccountSettingAction) + case general(GeneralSettingAction) + case appearance(AppearanceSettingAction) +} + +struct SettingEnvironment { + let dfClient: DFClient + let fileClient: FileClient + let deviceClient: DeviceClient + let loggerClient: LoggerClient + let hapticClient: HapticClient + let libraryClient: LibraryClient + let cookiesClient: CookiesClient + let databaseClient: DatabaseClient + let clipboardClient: ClipboardClient + let appDelegateClient: AppDelegateClient + let userDefaultsClient: UserDefaultsClient + let uiApplicationClient: UIApplicationClient + let authorizationClient: AuthorizationClient +} + +let settingReducer = Reducer.combine( + .init { state, action, environment in + switch action { + case .binding(\.$setting.galleryHost): + return .merge( + .init(value: .syncSetting), + environment.userDefaultsClient + .setValue(state.setting.galleryHost.rawValue, .galleryHost).fireAndForget() + ) + + case .binding(\.$setting.translatesTags): + var effects: [Effect] = [ + .init(value: .syncSetting) + ] + if state.setting.translatesTags { + effects.append(.init(value: .fetchTagTranslator)) + } + return .merge(effects) + + case .binding(\.$setting.preferredColorScheme): + return .merge( + .init(value: .syncSetting), + .init(value: .syncUserInterfaceStyle) + ) + + case .binding(\.$setting.appIconType): + return .merge( + .init(value: .syncSetting), + environment.uiApplicationClient.setAlternateIconName(state.setting.appIconType.filename) + .map { _ in SettingAction.syncAppIconType } + ) + + case .binding(\.$setting.enablesLandscape): + var effects: [Effect] = [ + .init(value: .syncSetting) + ] + if !state.setting.enablesLandscape && !environment.deviceClient.isPad() { + effects.append(environment.appDelegateClient.setPortraitOrientationMask().fireAndForget()) + } + return .merge(effects) + + case .binding(\.$setting.maximumScaleFactor): + if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { + state.setting.doubleTapScaleFactor = state.setting.maximumScaleFactor + } + return .init(value: .syncSetting) + + case .binding(\.$setting.doubleTapScaleFactor): + if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { + state.setting.maximumScaleFactor = state.setting.doubleTapScaleFactor + } + return .init(value: .syncSetting) + + case .binding(\.$setting.bypassesSNIFiltering): + return .merge( + .init(value: .syncSetting), + environment.hapticClient.generateFeedback(.soft).fireAndForget(), + environment.dfClient.setActive(state.setting.bypassesSNIFiltering).fireAndForget() + ) + + case .binding(\.$setting): + return .init(value: .syncSetting) + + case .binding(\.$route): + return .none + + case .binding: + return .merge( + .init(value: .syncUser), + .init(value: .syncSetting), + .init(value: .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 = environment.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 environment.uiApplicationClient.setUserInterfaceStyle(style) + .subscribe(on: DispatchQueue.main).fireAndForget() + + case .syncSetting: + return environment.databaseClient.updateSetting(state.setting).fireAndForget() + case .syncTagTranslator: + return environment.databaseClient.updateTagTranslator(state.tagTranslator).fireAndForget() + case .syncUser: + return environment.databaseClient.updateUser(state.user).fireAndForget() + + case .loadUserSettings: + return environment.databaseClient.fetchAppEnv().map(SettingAction.loadUserSettingsDone) + + case .loadUserSettingsDone(let appEnv): + state.setting = appEnv.setting + state.tagTranslator = appEnv.tagTranslator + state.user = appEnv.user + + var effects: [Effect] = [ + .init(value: .syncAppIconType), + .init(value: .syncUserInterfaceStyle) + ] + + if let value: String = environment.userDefaultsClient.getValue(.galleryHost), + let galleryHost = GalleryHost(rawValue: value) + { + state.setting.galleryHost = galleryHost + } + if environment.cookiesClient.shouldFetchIgneous { + effects.append(.init(value: .fetchIgneous)) + } + if environment.cookiesClient.didLogin { + effects.append(contentsOf: [ + .init(value: .fetchUserInfo), + .init(value: .fetchGreeting), + .init(value: .fetchFavoriteCategories), + .init(value: .fetchEhProfileIndex) + ]) + } + if state.setting.translatesTags { + effects.append(.init(value: .fetchTagTranslator)) + } + + return .merge(effects) + + case .createDefaultEhProfile: + return EhProfileRequest(action: .create, name: "EhPanda").effect.fireAndForget() + + case .fetchIgneous: + guard environment.cookiesClient.didLogin else { return .none } + return IgneousRequest().effect.map(SettingAction.fetchIgneousDone) + + case .fetchIgneousDone(let result): + var effects = [Effect]() + if case .success(let response) = result { + effects.append(environment.cookiesClient.setCredentials(response: response).fireAndForget()) + } + effects.append(.init(value: .account(.loadCookies))) + return .merge(effects) + + case .fetchUserInfo: + guard environment.cookiesClient.didLogin else { return .none } + let uid = environment.cookiesClient + .getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue + if !uid.isEmpty { + return UserInfoRequest(uid: uid).effect.map(SettingAction.fetchUserInfoDone) + } + return .none + + case .fetchUserInfoDone(let result): + if case .success(let user) = result { + state.updateUser(user) + return .init(value: .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 environment.cookiesClient.didLogin, + state.setting.showsNewDawnGreeting + else { return .none } + let requestEffect = GreetingRequest().effect + .map(SettingAction.fetchGreetingDone) + 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 .init(value: .syncUser) + case .failure(let error): + if case .parseFailed = error { + var greeting = Greeting() + greeting.updateTime = Date() + state.setGreeting(greeting) + return .init(value: .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 = .init(value: .syncTagTranslator) + } + let updatedDate = state.tagTranslator.updatedDate + let requestEffect = TagTranslatorRequest(language: language, updatedDate: updatedDate) + .effect.map(SettingAction.fetchTagTranslatorDone) + 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 .init(value: .syncTagTranslator) + case .failure(let error): + state.tagTranslatorLoadingState = .failed(error) + } + return .none + + case .fetchEhProfileIndex: + guard environment.cookiesClient.didLogin else { return .none } + return VerifyEhProfileRequest().effect.map(SettingAction.fetchEhProfileIndexDone) + + case .fetchEhProfileIndexDone(let result): + var effects = [Effect]() + + if case .success(let (profileValue, profileNotFound)) = result { + if let profileValue = profileValue { + let hostURL = Defaults.URL.host + let profileValueString = String(profileValue) + let selectedProfileKey = Defaults.Cookie.selectedProfile + + let cookieValue = environment.cookiesClient.getCookie(hostURL, selectedProfileKey) + if cookieValue.rawValue != profileValueString { + effects.append( + environment.cookiesClient.setOrEditCookie( + for: hostURL, key: selectedProfileKey, value: profileValueString + ) + .fireAndForget() + ) + } + } else if profileNotFound { + effects.append(.init(value: .createDefaultEhProfile)) + } else { + let message = "Found profile but failed in parsing value." + effects.append(environment.loggerClient.error(message, nil).fireAndForget()) + } + } + return effects.isEmpty ? .none : .merge(effects) + + case .fetchFavoriteCategories: + guard environment.cookiesClient.didLogin else { return .none } + return FavoriteCategoriesRequest().effect.map(SettingAction.fetchFavoriteCategoriesDone) + + case .fetchFavoriteCategoriesDone(let result): + if case .success(let categories) = result { + state.user.favoriteCategories = categories + } + return .none + + case .account(.login(.loginDone)): + return .merge( + environment.cookiesClient.removeYay().fireAndForget(), + environment.cookiesClient.fulfillAnotherHostField().fireAndForget(), + .init(value: .fetchIgneous), + .init(value: .fetchUserInfo), + .init(value: .fetchFavoriteCategories), + .init(value: .fetchEhProfileIndex) + ) + + case .account(.onLogoutConfirmButtonTapped): + state.user = User() + return .merge( + .init(value: .syncUser), + environment.cookiesClient.clearAll().fireAndForget(), + environment.databaseClient.removeImageURLs().fireAndForget(), + environment.libraryClient.clearWebImageDiskCache().fireAndForget() + ) + + case .account: + return .none + + case .general(.onTranslationsFilePicked(let url)): + return environment.fileClient.importTagTranslator(url).map(SettingAction.fetchTagTranslatorDone) + + case .general(.onRemoveCustomTranslations): + state.tagTranslator.hasCustomTranslations = false + state.tagTranslator.contents = .init() + return .init(value: .syncTagTranslator) + + case .general: + return .none + + case .appearance: + return .none + } + } + .binding(), + accountSettingReducer.pullback( + state: \.accountSettingState, + action: /SettingAction.account, + environment: { + .init( + hapticClient: $0.hapticClient, + cookiesClient: $0.cookiesClient, + clipboardClient: $0.clipboardClient, + uiApplicationClient: $0.uiApplicationClient + ) + } + ), + generalSettingReducer.pullback( + state: \.generalSettingState, + action: /SettingAction.general, + environment: { + .init( + fileClient: $0.fileClient, + loggerClient: $0.loggerClient, + libraryClient: $0.libraryClient, + databaseClient: $0.databaseClient, + uiApplicationClient: $0.uiApplicationClient, + authorizationClient: $0.authorizationClient + ) + } + ), + appearanceSettingReducer.pullback( + state: \.appearanceSettingState, + action: /SettingAction.appearance, + environment: { _ in + .init() + } + ) +) diff --git a/EhPanda/View/Setting/EhPandaView.swift b/EhPanda/View/Setting/EhPandaView.swift index 7a65ed7c..982a2ef8 100644 --- a/EhPanda/View/Setting/EhPandaView.swift +++ b/EhPanda/View/Setting/EhPandaView.swift @@ -7,91 +7,237 @@ import SwiftUI -struct EhPandaView: View, StoreAccessor { - @EnvironmentObject var store: Store - - private var contacts: [Info] { - [ - Info(url: "https://ehpanda.app", text: "Website".localized), - Info(url: "https://github.com/tatsuz0u/EhPanda", text: "GitHub"), - Info(url: "https://discord.gg/BSBE9FCBTq", text: "Discord"), - Info(url: "https://t.me/ehpanda", text: "Telegram"), - Info( - url: "altstore://source?url=" - + "https://github.com/tatsuz0u" - + "/EhPanda/raw/main/AltStore.json", - text: "AltStore Source".localized - ) - ] - } - - private var acknowledgements: [Info] { +struct EhPandaView: View { + private var version: String { [ - Info(url: "https://github.com/taylorlannister", text: "taylorlannister"), - Info(url: "https://github.com/caxerx", text: "caxerx"), - Info(url: "https://github.com/honjow", text: "honjow"), - Info(url: "https://github.com/tid-kijyun/Kanna", text: "Kanna"), - Info(url: "https://github.com/rebeloper/AlertKit", text: "AlertKit"), - Info(url: "https://github.com/onevcat/Kingfisher", text: "Kingfisher"), - Info(url: "https://github.com/fermoya/SwiftUIPager", text: "SwiftUIPager"), - Info(url: "https://github.com/SwiftyBeaver/SwiftyBeaver", text: "SwiftyBeaver"), - Info(url: "https://github.com/paololeonardi/WaterfallGrid", text: "WaterfallGrid"), - Info(url: "https://github.com/marksands/BetterCodable", text: "BetterCodable"), - Info(url: "https://github.com/ddddxxx/SwiftyOpenCC", text: "SwiftyOpenCC"), - Info(url: "https://github.com/honkmaster/TTProgressHUD", text: "TTProgressHUD"), - Info(url: "https://github.com/EhTagTranslation/Database", text: "EhTagTranslation/Database") + R.string.localizable.ehpandaViewDescriptionVersion(), + AppUtil.version, "(\(AppUtil.build))" ] - } - - private var version: String { - ["Version".localized, AppUtil.version, "(\(AppUtil.build))"].joined(separator: " ") + .joined(separator: " ") } var body: some View { HStack { VStack(alignment: .leading) { - Text("Copyright © 2022 荒木辰造").captionTextStyle() - Text(version).captionTextStyle() + Text(R.string.constant.ehpandaCopyright()) + Text(version) } + .foregroundStyle(.gray).font(.caption2.bold()) Spacer() } .padding(.horizontal) Form { Section { ForEach(contacts) { contact in - LinkRow(url: contact.url.safeURL(), text: contact.text) + LinkRow(urlString: contact.urlString, text: contact.text) } } - Section("Acknowledgement".localized) { + Section(R.string.localizable.ehpandaViewSectionTitleSpecialThanks()) { + ForEach(specialThanks) { specialThank in + LinkRow(urlString: specialThank.urlString, text: specialThank.text) + } + } + Section(R.string.localizable.ehpandaViewSectionTitleCodeLevelContributors()) { + ForEach(codeLevelContributors) { codeLevelContributor in + LinkRow(urlString: codeLevelContributor.urlString, text: codeLevelContributor.text) + } + } + Section(R.string.localizable.ehpandaViewSectionTitleTranslationContributors()) { + ForEach(translationContributors) { translationContributor in + LinkRow(urlString: translationContributor.urlString, text: translationContributor.text) + } + } + Section(R.string.localizable.ehpandaViewSectionTitleAcknowledgements()) { ForEach(acknowledgements) { acknowledgement in - LinkRow(url: acknowledgement.url.safeURL(), text: acknowledgement.text) + LinkRow(urlString: acknowledgement.urlString, text: acknowledgement.text) } } } - .navigationBarTitle("EhPanda") + .navigationTitle(R.string.localizable.ehpandaViewTitleEhPanda()) } -} -private struct Info: Identifiable { - var id: String { url } + // MARK: Contacts + private let contacts: [Info] = {[ + .init( + urlString: R.string.constant.ehpandaContactsLinkWebsite(), + text: R.string.localizable.ehpandaViewButtonWebsite() + ), + .init( + urlString: R.string.constant.ehpandaContactsLinkGitHub(), + text: R.string.constant.ehpandaContactsTextGitHub() + ), + .init( + urlString: R.string.constant.ehpandaContactsLinkDiscord(), + text: R.string.constant.ehpandaContactsTextDiscord() + ), + .init( + urlString: R.string.constant.ehpandaContactsLinkTelegram(), + text: R.string.constant.ehpandaContactsTextTelegram() + ), + .init( + urlString: R.string.constant.ehpandaContactsLinkAltStore(), + text: R.string.localizable.ehpandaViewButtonAltStoreSource() + ) + ]}() - let url: String - let text: String + // MARK: Special thanks + private let specialThanks: [Info] = {[ + .init( + urlString: R.string.constant.ehpandaSpecialThanksLinkTaylorlannister(), + text: R.string.constant.ehpandaSpecialThanksTextTaylorlannister() + ), + .init( + urlString: R.string.constant.ehpandaSpecialThanksLinkLuminescent_yq(), + text: R.string.constant.ehpandaSpecialThanksTextLuminescent_yq() + ), + .init( + urlString: R.string.constant.ehpandaSpecialThanksLinkCaxerx(), + text: R.string.constant.ehpandaSpecialThanksTextCaxerx() + ), + .init( + urlString: R.string.constant.ehpandaSpecialThanksLinkHonjow(), + text: R.string.constant.ehpandaSpecialThanksTextHonjow() + ) + ]}() + + // MARK: Code level contributors + private let codeLevelContributors: [Info] = {[ + .init( + urlString: R.string.constant.ehpandaCodeLevelContributorsLinkTatsuz0u(), + text: R.string.constant.ehpandaCodeLevelContributorsTextTatsuz0u() + ), + .init( + urlString: R.string.constant.ehpandaCodeLevelContributorsLinkLengYue(), + text: R.string.constant.ehpandaCodeLevelContributorsTextLengYue() + ) + ]}() + + // MARK: Translation contributors + private let translationContributors: [Info] = {[ + .init( + urlString: R.string.constant.ehpandaTranslationContributorsLinkTatsuz0u(), + text: R.string.constant.ehpandaTranslationContributorsTextTatsuz0u() + ), + .init( + urlString: R.string.constant.ehpandaTranslationContributorsLinkPaulHaeussler(), + text: R.string.constant.ehpandaTranslationContributorsTextPaulHaeussler() + ), + .init( + urlString: R.string.constant.ehpandaTranslationContributorsLinkCaxerx(), + text: R.string.constant.ehpandaTranslationContributorsTextCaxerx() + ), + .init( + urlString: R.string.constant.ehpandaTranslationContributorsLinkNyaanim(), + text: R.string.constant.ehpandaTranslationContributorsTextNyaanim() + ) + ]}() + + // MARK: Acknowledgements + private let acknowledgements: [Info] = {[ + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkKanna(), + text: R.string.constant.ehpandaAcknowledgementsTextKanna() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkRswift(), + text: R.string.constant.ehpandaAcknowledgementsTextRswift() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkAlertKit(), + text: R.string.constant.ehpandaAcknowledgementsTextAlertKit() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkColorful(), + text: R.string.constant.ehpandaAcknowledgementsTextColorful() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkFilePicker(), + text: R.string.constant.ehpandaAcknowledgementsTextFilePicker() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkKingfisher(), + text: R.string.constant.ehpandaAcknowledgementsTextKingfisher() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkSwiftUIPager(), + text: R.string.constant.ehpandaAcknowledgementsTextSwiftUIPager() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkSwiftyBeaver(), + text: R.string.constant.ehpandaAcknowledgementsTextSwiftyBeaver() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkWaterfallGrid(), + text: R.string.constant.ehpandaAcknowledgementsTextWaterfallGrid() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkSwiftyOpenCC(), + text: R.string.constant.ehpandaAcknowledgementsTextSwiftyOpenCC() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkUIImageColors(), + text: R.string.constant.ehpandaAcknowledgementsTextUIImageColors() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkSFSafeSymbols(), + text: R.string.constant.ehpandaAcknowledgementsTextSFSafeSymbols() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkTTProgressHUD(), + text: R.string.constant.ehpandaAcknowledgementsTextTTProgressHUD() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkSwiftUINavigation(), + text: R.string.constant.ehpandaAcknowledgementsTextSwiftUINavigation() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkEhTagTranslationDatabase(), + text: R.string.constant.ehpandaAcknowledgementsTextEhTagTranslationDatabase() + ), + .init( + urlString: R.string.constant.ehpandaAcknowledgementsLinkTCA(), + text: R.string.constant.ehpandaAcknowledgementsTextTCA() + ) + ]}() } +// MARK: LinkRow private struct LinkRow: View { - let url: URL - let text: String + private let urlString: String + private let text: String + + init(urlString: String, text: String) { + self.urlString = urlString + self.text = text + } var body: some View { - Link(destination: url, label: { - Text(text).fontWeight(.medium).foregroundColor(.primary).withArrow() - }) + ZStack { + let text = Text(text).fontWeight(.medium) + if let url = URL(string: urlString) { + Link(destination: url) { + text.withArrow() + } + } else { + text + } + } + .foregroundColor(.primary) } } -private extension Text { - func captionTextStyle() -> some View { - self.fontWeight(.bold).foregroundStyle(.gray).font(.caption2) +// MARK: Definition +private struct Info: Identifiable { + var id: String { urlString } + + let urlString: String + let text: String +} + +struct EhPandaView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EhPandaView() + } } } diff --git a/EhPanda/View/Setting/EhSettingView.swift b/EhPanda/View/Setting/EhSettingView.swift deleted file mode 100644 index 580a72f5..00000000 --- a/EhPanda/View/Setting/EhSettingView.swift +++ /dev/null @@ -1,1181 +0,0 @@ -// -// EhSettingView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/08/07. -// - -import SwiftUI - -struct EhSettingView: View, StoreAccessor { - @EnvironmentObject var store: Store - - @State private var ehSetting: EhSetting? - @State private var loadingFlag = false - @State private var loadError: AppError? - @State private var submittingFlag = false - @State private var shouldHideKeyboard = "" - - private var title: String { - AppUtil.galleryHost.rawValue + " " + "Setting".localized - } - - // MARK: EhSettingView - var body: some View { - Group { - if loadingFlag || submittingFlag { - LoadingView().tint(nil) - } else if let error = loadError { - ErrorView(error: error, retryAction: fetchEhSetting).tint(nil) - } else if let ehSettingBinding = Binding($ehSetting) { - form(ehSettingBinding: ehSettingBinding) - } else { - Circle().frame(width: 1).opacity(0.1) - } - } - .onAppear { - guard ehSetting == nil else { return } - fetchEhSetting() - } - .onDisappear { - guard let set = ehSetting?.ehProfiles.filter({ - AppUtil.verifyEhPandaProfileName(with: $0.name) - }).first?.value else { return } - CookiesUtil.set(for: Defaults.URL.host.safeURL(), key: Defaults.Cookie.selectedProfile, value: String(set)) - } - .toolbar(content: toolbar).navigationTitle(title) - } - // MARK: Form - private func form(ehSettingBinding: Binding) -> some View { - Form { - Group { - EhProfileSection( - ehSetting: ehSettingBinding, shouldHideKeyboard: $shouldHideKeyboard, - performEhProfileAction: performEhProfileAction - ) - ImageLoadSettingsSection(ehSetting: ehSettingBinding) - ImageSizeSettingsSection(ehSetting: ehSettingBinding) - GalleryNameDisplaySection(ehSetting: ehSettingBinding) - ArchiverSettingsSection(ehSetting: ehSettingBinding) - FrontPageSettingsSection(ehSetting: ehSettingBinding) - FavoritesSection(ehSetting: ehSettingBinding, shouldHideKeyboard: $shouldHideKeyboard) - RatingsSection(ehSetting: ehSettingBinding, shouldHideKeyboard: $shouldHideKeyboard) - TagNamespacesSection(ehSetting: ehSettingBinding) - TagFilteringThresholdSection(ehSetting: ehSettingBinding) - } - Group { - TagWatchingThresholdSection(ehSetting: ehSettingBinding) - ExcludedLanguagesSection(ehSetting: ehSettingBinding) - ExcludedUploadersSection(ehSetting: ehSettingBinding, shouldHideKeyboard: $shouldHideKeyboard) - SearchResultCountSection(ehSetting: ehSettingBinding) - ThumbnailSettingsSection(ehSetting: ehSettingBinding) - ThumbnailScalingSection(ehSetting: ehSettingBinding) - ViewportOverrideSection(ehSetting: ehSettingBinding) - GalleryCommentsSection(ehSetting: ehSettingBinding) - GalleryTagsSection(ehSetting: ehSettingBinding) - GalleryPageNumberingSection(ehSetting: ehSettingBinding) - } - Group { - HathLocalNetworkHostSection(ehSetting: ehSettingBinding, shouldHideKeyboard: $shouldHideKeyboard) - OriginalImagesSection(ehSetting: ehSettingBinding) - MultiplePageViewerSection(ehSetting: ehSettingBinding) - } - } - .transition(AppUtil.opacityTransition) - } - // MARK: Toolbar - private func toolbar() -> some ToolbarContent { - Group { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - store.dispatch(.setSettingViewSheetState(.webviewConfig)) - } label: { - Image(systemName: "globe") - } - .disabled(setting.bypassesSNIFiltering) - } - ToolbarItem(placement: .confirmationAction) { - Button(action: submitEhSettingChanges) { - Image(systemName: "icloud.and.arrow.up") - } - .disabled(ehSetting == nil) - } - ToolbarItem(placement: .keyboard) { - HStack { - Spacer() - Button("Done") { - shouldHideKeyboard = UUID().uuidString - } - } - } - } - } -} - -private extension EhSettingView { - // MARK: Networking - func fetchEhSetting() { - loadError = nil - guard !loadingFlag else { return } - loadingFlag = true - - let token = SubscriptionToken() - EhSettingRequest() - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - loadingFlag = false - if case .failure(let error) = completion { - Logger.error(error) - loadError = error - - Logger.error( - "EhSettingRequest failed", - context: [ "Error": error ] - ) - } - token.unseal() - } receiveValue: { ehSetting in - self.ehSetting = ehSetting - - Logger.info( - "EhSettingRequest succeeded", - context: [ "EhProfiles": ehSetting.ehProfiles ] - ) - } - .seal(in: token) - } - func submitEhSettingChanges() { - guard let ehSetting = ehSetting, !submittingFlag else { return } - - submittingFlag = true - - let token = SubscriptionToken() - SubmitEhSettingChangesRequest(ehSetting: ehSetting) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - submittingFlag = false - if case .failure(let error) = completion { - Logger.error(error) - loadError = error - - Logger.error( - "SubmitEhSettingChangesRequest failed", - context: [ "Error": error ] - ) - } - token.unseal() - } receiveValue: { ehSetting in - self.ehSetting = ehSetting - - Logger.info( - "SubmitEhSettingChangesRequest succeeded", - context: [ "EhProfiles": ehSetting.ehProfiles ] - ) - } - .seal(in: token) - } - func performEhProfileAction(action: EhProfileAction?, name: String? = nil, set: Int) { - guard !submittingFlag else { return } - submittingFlag = true - - let token = SubscriptionToken() - EhProfileRequest(action: action, name: name, set: set) - .publisher.receive(on: DispatchQueue.main) - .sink { completion in - submittingFlag = false - if case .failure(let error) = completion { - Logger.error(error) - loadError = error - - Logger.error( - "EhProfileRequest failed", - context: [ - "Action": action as Any, "Name": name as Any, - "Set": set, "Error": error - ] - ) - } - token.unseal() - } receiveValue: { ehSetting in - self.ehSetting = ehSetting - - Logger.info( - "EhProfileRequest succeeded", - context: [ - "Action": action as Any, "Name": name as Any, - "Set": set as Any, "EhProfiles": ehSetting.ehProfiles - ] - ) - } - .seal(in: token) - } -} - -// MARK: EhProfileSection -private struct EhProfileSection: View { - @Binding private var ehSetting: EhSetting - @State private var selection: EhProfile - @State private var newName: String - @Binding private var shouldHideKeyboard: String - - @FocusState private var isFocused - @State private var dialogPresented = false - - private let performEhProfileAction: (EhProfileAction?, String?, Int) -> Void - - init( - ehSetting: Binding, shouldHideKeyboard: Binding, - performEhProfileAction: @escaping (EhProfileAction?, String?, Int) -> Void - ) { - let selection: EhProfile = ehSetting.wrappedValue.ehProfiles - .filter(\.isSelected).first.forceUnwrapped - - _ehSetting = ehSetting - _selection = State(initialValue: selection) - _newName = State(initialValue: selection.name) - _shouldHideKeyboard = shouldHideKeyboard - self.performEhProfileAction = performEhProfileAction - } - - var body: some View { - Section("Profile Settings".localized) { - HStack { - Text("Selected profile") - Spacer() - Picker(selection: $selection) { - ForEach(ehSetting.ehProfiles) { ehProfile in - Text(ehProfile.name).tag(ehProfile) - } - } label: { - Text(selection.name) - } - .pickerStyle(.menu) - } - if !selection.isDefault { - Button("Set as default") { - performEhProfileAction(.default, nil, selection.value) - } - Button("Delete profile", role: .destructive) { - dialogPresented = true - } - } - } - .confirmationDialog( - "Are you sure to delete this profile?", isPresented: $dialogPresented, titleVisibility: .visible - ) { - Button("Delete", role: .destructive) { - performEhProfileAction(.delete, nil, selection.value) - } - } - .onChange(of: selection) { - performEhProfileAction(nil, nil, $0.value) - } - .textCase(nil) - Section { - SettingTextField(text: $newName, width: nil, alignment: .leading, background: .clear).focused($isFocused) - Button("Rename") { - performEhProfileAction(.rename, newName, selection.value) - } - .disabled(isFocused) - if ehSetting.ehProfiles.count < 10 { - Button("Create new") { - performEhProfileAction(.create, newName, selection.value) - } - .disabled(isFocused) - } - } - .onChange(of: shouldHideKeyboard) { _ in - isFocused = false - } - } -} - -// MARK: ImageLoadSettingsSection -private struct ImageLoadSettingsSection: View { - @Binding private var ehSetting: EhSetting - - private var capableSettings: [EhSettingLoadThroughHathSetting] { - EhSettingLoadThroughHathSetting.allCases.filter { setting in - setting <= ehSetting.capableLoadThroughHathSetting - } - } - // swiftlint:disable line_length - private var browsingCountryKey: LocalizedStringKey { - LocalizedStringKey( - "You appear to be browsing the site from **PLACEHOLDER** or use a VPN or proxy in this country, which means the site will try to load images from Hath clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.".localized - .replacingOccurrences(of: "PLACEHOLDER", with: ehSetting.literalBrowsingCountry.localized) - ) - } - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section( - header: Text("Image Load Settings"), footer: Text(ehSetting.loadThroughHathSetting.description.localized) - ) { - Text("Load images through the Hath network") - Picker(selection: $ehSetting.loadThroughHathSetting) { - ForEach(capableSettings) { setting in - Text(setting.value.localized).tag(setting) - } - } label: { - Text(ehSetting.loadThroughHathSetting.value.localized) - } - .pickerStyle(.menu) - } - .textCase(nil) - Section(browsingCountryKey) { - Picker("Browsing country", selection: $ehSetting.browsingCountry) { - ForEach(EhSettingBrowsingCountry.allCases) { country in - Text(country.name.localized).tag(country) - .foregroundColor(country == ehSetting.browsingCountry ? .accentColor : .primary) - } - } - } - .textCase(nil) - } -} - -// MARK: ImageSizeSettingsSection -private struct ImageSizeSettingsSection: View { - @Binding private var ehSetting: EhSetting - - private var capableResolutions: [EhSettingImageResolution] { - EhSettingImageResolution.allCases.filter { resolution in - resolution <= ehSetting.capableImageResolution - } - } - - // swiftlint:disable line_length - private let imageResolutionDescription = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000." - private let imageSizeDescription = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)" - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Image Size Settings").newlineBold() + Text(imageResolutionDescription.localized)) { - HStack { - Text("Image resolution") - Spacer() - Picker(selection: $ehSetting.imageResolution) { - ForEach(capableResolutions) { setting in - Text(setting.value.localized).tag(setting) - } - } label: { - Text(ehSetting.imageResolution.value.localized) - } - .pickerStyle(.menu) - } - } - .textCase(nil) - Section(imageSizeDescription.localized) { - Text("Image size") - ValuePicker(title: "Horizontal", value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px") - ValuePicker(title: "Vertical", value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px") - } - .textCase(nil) - } -} - -// MARK: GalleryNameDisplaySection -private struct GalleryNameDisplaySection: View { - @Binding private var ehSetting: EhSetting - - // swiftlint:disable line_length - private let galleryNameDescription = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?" - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Gallery Name Display").newlineBold() + Text(galleryNameDescription.localized)) { - HStack { - Text("Gallery name") - Spacer() - Picker(selection: $ehSetting.galleryName) { - ForEach(EhSettingGalleryName.allCases) { name in - Text(name.value.localized).tag(name) - } - } label: { - Text(ehSetting.galleryName.value.localized) - } - .pickerStyle(.menu) - } - } - .textCase(nil) - } -} - -// MARK: ArchiverSettingsSection -private struct ArchiverSettingsSection: View { - @Binding private var ehSetting: EhSetting - - // swiftlint:disable line_length - private let archiverSettingsDescription = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here." - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Archiver Settings").newlineBold() + Text(archiverSettingsDescription.localized)) { - Text("Archiver behavior") - Picker(selection: $ehSetting.archiverBehavior) { - ForEach(EhSettingArchiverBehavior.allCases) { behavior in - Text(behavior.value.localized).tag(behavior) - } - } label: { - Text(ehSetting.archiverBehavior.value.localized) - } - .pickerStyle(.menu) - } - .textCase(nil) - } -} - -// MARK: FrontPageSettingsSection -private struct FrontPageSettingsSection: View { - @Binding private var ehSetting: EhSetting - - private var categoryBindings: [Binding] { - $ehSetting.disabledCategories.map({ $0 }) - } - - // swiftlint:disable line_length - private let displayModeDescription = "Which display mode would you like to use on the front and search pages?" - private let categoriesDescription = "What categories would you like to show by default on the front page and in searches?" - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Front Page Settings").newlineBold() + Text(displayModeDescription.localized)) { - HStack { - Text("Display mode") - Spacer() - Picker(selection: $ehSetting.displayMode) { - ForEach(EhSettingDisplayMode.allCases) { mode in - Text(mode.value.localized).tag(mode) - } - } label: { - Text(ehSetting.displayMode.value.localized) - } - .pickerStyle(.menu) - } - } - .textCase(nil) - Section(categoriesDescription.localized) { - CategoryView(bindings: categoryBindings) - } - .textCase(nil) - } -} - -// MARK: FavoritesSection -private struct FavoritesSection: View { - @Binding private var ehSetting: EhSetting - @Binding private var shouldHideKeyboard: String - @FocusState private var isFocused - - private var tuples: [(Category, Binding)] { - Category.allFavoritesCases.enumerated().map { index, category in - (category, $ehSetting.favoriteNames[index]) - } - } - - // swiftlint:disable line_length - private let favoriteNamesDescription = "Here you can choose and rename your favorite categories." - private let sortOrderDescription = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting." - // swiftlint:enable line_length - - init(ehSetting: Binding, shouldHideKeyboard: Binding) { - _ehSetting = ehSetting - _shouldHideKeyboard = shouldHideKeyboard - } - - var body: some View { - Section(header: Text("Favorites").newlineBold() + Text(favoriteNamesDescription.localized)) { - 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) - } - } - .onChange(of: shouldHideKeyboard) { _ in - isFocused = false - } - .textCase(nil) - Section(sortOrderDescription.localized) { - HStack { - Text("Favorites sort order") - Spacer() - Picker(selection: $ehSetting.favoritesSortOrder) { - ForEach(EhSettingFavoritesSortOrder.allCases) { order in - Text(order.value.localized).tag(order) - } - } label: { - Text(ehSetting.favoritesSortOrder.value.localized) - } - .pickerStyle(.menu) - } - } - .textCase(nil) - } -} - -// MARK: RatingsSection -private struct RatingsSection: View { - @Binding private var ehSetting: EhSetting - @Binding private var shouldHideKeyboard: String - @FocusState var isFocused - - // swiftlint:disable line_length - private let ratingsDescription = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works." - // swiftlint:enable line_length - - init(ehSetting: Binding, shouldHideKeyboard: Binding) { - _ehSetting = ehSetting - _shouldHideKeyboard = shouldHideKeyboard - } - - var body: some View { - Section(header: Text("Ratings").newlineBold() + Text(ratingsDescription.localized)) { - HStack { - Text("Ratings color") - Spacer() - SettingTextField(text: $ehSetting.ratingsColor, promptText: "RRGGB", width: 80).focused($isFocused) - } - } - .onChange(of: shouldHideKeyboard) { _ in - isFocused = false - } - .textCase(nil) - } -} - -// MARK: TagNamespacesSection -private struct TagNamespacesSection: View { - @Binding private var ehSetting: EhSetting - - private var tuples: [(String, Binding)] { - TagCategory.allCases.dropLast().enumerated().map { index, value in - (value.rawValue.firstLetterCapitalized, $ehSetting.excludedNamespaces[index]) - } - } - - // swiftlint:disable line_length - private let tagNamespacesDescription = "If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces." - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Tag Namespaces").newlineBold() + Text(tagNamespacesDescription.localized)) { - ExcludeView(tuples: tuples) - } - .textCase(nil) - } -} - -private struct ExcludeView: View { - private let tuples: [(String, Binding)] - - private let gridItems = [ - GridItem(.adaptive( - minimum: DeviceUtil.isPadWidth ? 100 : 80, maximum: 100 - )) - ] - - init(tuples: [(String, Binding)]) { - self.tuples = tuples - } - - var body: some View { - LazyVGrid(columns: gridItems) { - ForEach(tuples, id: \.0) { text, isExcluded in - ZStack { - Text(text.localized).bold().opacity(isExcluded.wrappedValue ? 0 : 1) - ZStack { - Text(text.localized) - let width = (CGFloat(text.count) * 8) + 8 - let line = Rectangle().frame(width: width, height: 1) - VStack(spacing: 2) { - line - line - } - } - .foregroundColor(.red).opacity(isExcluded.wrappedValue ? 1 : 0) - } - .onTapGesture { - HapticUtil.generateFeedback(style: .soft) - withAnimation { isExcluded.wrappedValue.toggle() } - } - } - } - .padding(.vertical) - } -} - -// MARK: TagFilteringThresholdSection -private struct TagFilteringThresholdSection: View { - @Binding private var ehSetting: EhSetting - - // swiftlint:disable line_length - private let tagFilteringThresholdDescription = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999." - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section( - header: Text("Tag Filtering Threshold").newlineBold() + Text(tagFilteringThresholdDescription.localized) - ) { - ValuePicker(title: "Tag Filtering Threshold", value: $ehSetting.tagFilteringThreshold, range: -9999...0) - } - .textCase(nil) - } -} - -// MARK: TagWatchingThresholdSection -private struct TagWatchingThresholdSection: View { - @Binding private var ehSetting: EhSetting - - // swiftlint:disable line_length - private let tagWatchingThresholdDescription = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999." - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section( - header: Text("Tag Watching Threshold").newlineBold() + Text(tagWatchingThresholdDescription.localized - )) { - ValuePicker(title: "Tag Watching Threshold", value: $ehSetting.tagWatchingThreshold, range: 0...9999) - } - .textCase(nil) - } -} - -// MARK: ExcludedLanguagesSection -private struct ExcludedLanguagesSection: View { - @Binding private var ehSetting: EhSetting -// @State private var showDetailIndex: Int? - - private var languageBindings: [Binding] { - $ehSetting.excludedLanguages.map( { $0 }) - } - private let languages = [ - "Japanese", "English", "Chinese", "Dutch", - "French", "German", "Hungarian", "Italian", - "Korean", "Polish", "Portuguese", "Russian", - "Spanish", "Thai", "Vietnamese", "N/A", "Other" - ] - - // swiftlint:disable line_length - private let excludedLanguagesDescription = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query." - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Excluded Languages").newlineBold() + Text(excludedLanguagesDescription.localized)) { - HStack { - Text("").frame(width: DeviceUtil.windowW * 0.25) - ForEach(["Original", "Translated", "Rewrite"], id: \.self) { category in - Color.clear.overlay { - Text(category.localized).lineLimit(1).font(.subheadline).fixedSize() - } - } - } - ForEach(0..<(languageBindings.count / 3) + 1) { index in - ExcludeRow( - title: languages[index], - bindings: [-1, 0, 1].map { num in - let index = index * 3 + num - - guard index != -1 - else { return .constant(false) } - return languageBindings[index] - }, - isFirstRow: index == 0 - ) - } - } - .textCase(nil) - } -} - -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 { - HStack { - Text(title.localized).lineLimit(1).font(.subheadline).fixedSize() - Spacer() - } - .frame(width: DeviceUtil.windowW * 0.25) - ForEach(0..) { - _isOn = isOn - } - - var body: some View { - Color.clear.overlay { - Image(systemName: isOn ? "nosign" : "circle").foregroundColor(isOn ? .red : .primary).font(.title) - } - .onTapGesture { - withAnimation { isOn.toggle() } - HapticUtil.generateFeedback(style: .soft) - } - } -} - -// MARK: ExcludedUploadersSection -private struct ExcludedUploadersSection: View { - @Binding private var ehSetting: EhSetting - @Binding private var shouldHideKeyboard: String - @FocusState var isFocused - - // swiftlint:disable line_length - private let excludedUploadersDescription = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query." - private var exclusionSlotsKey: LocalizedStringKey { - LocalizedStringKey("You are currently using **\(ehSetting.excludedUploaders.lineCount) / 1000** exclusion slots.") - } - // swiftlint:enable line_length - - init(ehSetting: Binding, shouldHideKeyboard: Binding) { - _ehSetting = ehSetting - _shouldHideKeyboard = shouldHideKeyboard - } - - var body: some View { - Section( - header: Text("Excluded Uploaders").newlineBold() + Text(excludedUploadersDescription.localized), - footer: Text(exclusionSlotsKey) - ) { - TextEditor(text: $ehSetting.excludedUploaders).textInputAutocapitalization(.none) - .frame(maxHeight: DeviceUtil.windowH * 0.3).disableAutocorrection(true).focused($isFocused) - } - .onChange(of: shouldHideKeyboard) { _ in - isFocused = false - } - .textCase(nil) - } -} - -// MARK: SearchResultCountSection -private struct SearchResultCountSection: View { - @Binding private var ehSetting: EhSetting - - private var capableCounts: [EhSettingSearchResultCount] { - EhSettingSearchResultCount.allCases.filter { count in - count <= ehSetting.capableSearchResultCount - } - } - - // swiftlint:disable line_length - private let searchResultCountDescription = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)" - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Search Result Count").newlineBold() + Text(searchResultCountDescription.localized)) { - HStack { - Text("Result count") - Spacer() - Picker(selection: $ehSetting.searchResultCount) { - ForEach(capableCounts) { count in - Text(String(count.value)).tag(count) - } - } label: { - Text(String(ehSetting.searchResultCount.value)) - } - .pickerStyle(.menu) - } - } - .textCase(nil) - } -} - -// MARK: ThumbnailSettingsSection -private struct ThumbnailSettingsSection: View { - @Binding private var ehSetting: EhSetting - - private var capableSizes: [EhSettingThumbnailSize] { - EhSettingThumbnailSize.allCases.filter { size in - size <= ehSetting.capableThumbnailConfigSize - } - } - private var capableRows: [EhSettingThumbnailRows] { - EhSettingThumbnailRows.allCases.filter { row in - row <= ehSetting.capableThumbnailConfigRows - } - } - - // swiftlint:disable line_length - private let thumbnailLoadTimingDescription = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?" - private let thumbnailConfigurationDescription = "You can set a default thumbnail configuration for all galleries you visit." - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section( - header: Text("Thumbnail Settings").newlineBold() + Text(thumbnailLoadTimingDescription.localized), - footer: Text(ehSetting.thumbnailLoadTiming.description.localized) - ) { - HStack { - Text("Thumbnail load timing") - Spacer() - Picker(selection: $ehSetting.thumbnailLoadTiming) { - ForEach(EhSettingThumbnailLoadTiming.allCases) { timing in - Text(timing.value.localized).tag(timing) - } - } label: { - Text(ehSetting.thumbnailLoadTiming.value.localized) - } - .pickerStyle(.menu) - } - } - .textCase(nil) - Section(thumbnailConfigurationDescription.localized) { - HStack { - Text("Size") - Spacer() - Picker(selection: $ehSetting.thumbnailConfigSize) { - ForEach(capableSizes) { size in - Text(size.value.localized).tag(size) - } - } label: { - Text(ehSetting.thumbnailConfigSize.value.localized) - } - .pickerStyle(.segmented).frame(width: 200) - } - HStack { - Text("Rows") - Spacer() - Picker(selection: $ehSetting.thumbnailConfigRows) { - ForEach(capableRows) { row in - Text(row.value).tag(row) - } - } label: { - Text(ehSetting.thumbnailConfigRows.value) - } - .pickerStyle(.segmented).frame(width: 200) - } - } - .textCase(nil) - } -} - -// MARK: ThumbnailScalingSection -private struct ThumbnailScalingSection: View { - @Binding private var ehSetting: EhSetting - - // swiftlint:disable line_length - private let thumbnailScalingDescription = "Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%." - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Thumbnail Scaling").newlineBold() + Text(thumbnailScalingDescription.localized)) { - ValuePicker(title: "Scale factor", value: $ehSetting.thumbnailScaleFactor, range: 75...150, unit: "%") - } - .textCase(nil) - } -} - -// MARK: ViewportOverrideSection -private struct ViewportOverrideSection: View { - @Binding private var ehSetting: EhSetting - - // swiftlint:disable line_length - private let viewportOverrideDescription = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400." - // swiftlint:enable line_length - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(header: Text("Viewport Override").newlineBold() + Text(viewportOverrideDescription.localized)) { - ValuePicker(title: "Virtual width", value: $ehSetting.viewportVirtualWidth, range: 0...9999, unit: "px") - } - .textCase(nil) - } -} - -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 { - VStack { - HStack { - Text(title.localized) - Spacer() - Text(String(Int(value)) + unit).foregroundStyle(.tint) - } - } - Slider( - value: $value, in: range, step: 1, - minimumValueLabel: Text(String(Int(range.lowerBound)) + unit).fontWeight(.medium).font(.callout), - maximumValueLabel: Text(String(Int(range.upperBound)) + unit).fontWeight(.medium).font(.callout), - label: EmptyView.init - ) - } -} - -// MARK: GalleryCommentsSection -private struct GalleryCommentsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section("Gallery Comments".localized) { - HStack { - Text("Comments sort order") - Spacer() - Picker(selection: $ehSetting.commentsSortOrder) { - ForEach(EhSettingCommentsSortOrder.allCases) { order in - Text(order.value.localized).tag(order) - } - } label: { - Text(ehSetting.commentsSortOrder.value.localized) - } - .pickerStyle(.menu) - } - HStack { - Text("Comment votes show timing") - Spacer() - Picker(selection: $ehSetting.commentVotesShowTiming) { - ForEach(EhSettingCommentVotesShowTiming.allCases) { timing in - Text(timing.value.localized).tag(timing) - } - } label: { - Text(ehSetting.commentVotesShowTiming.value.localized) - } - .pickerStyle(.menu) - } - } - .textCase(nil) - } -} - -// MARK: GalleryTagsSection -private struct GalleryTagsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section("Gallery Tags".localized) { - HStack { - Text("Tags sort order") - Spacer() - Picker(selection: $ehSetting.tagsSortOrder) { - ForEach(EhSettingTagsSortOrder.allCases) { order in - Text(order.value.localized).tag(order) - } - } label: { - Text(ehSetting.tagsSortOrder.value.localized) - } - .pickerStyle(.menu) - } - } - .textCase(nil) - } -} - -// MARK: GalleryPageNumberingSection -private struct GalleryPageNumberingSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section("Gallery Page Numbering".localized) { - Toggle("Show gallery page numbers", isOn: $ehSetting.galleryShowPageNumbers) - } - .textCase(nil) - } -} - -// MARK: HathLocalNetworkHostSection -private struct HathLocalNetworkHostSection: View { - @Binding private var ehSetting: EhSetting - @Binding private var shouldHideKeyboard: String - @FocusState var isFocused - - // swiftlint:disable line_length - private let hathLocalNetworkHostDescription = "This setting can be used if you have a Hath client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work." - // swiftlint:enable line_length - - init(ehSetting: Binding, shouldHideKeyboard: Binding) { - _ehSetting = ehSetting - _shouldHideKeyboard = shouldHideKeyboard - } - - var body: some View { - Section( - header: Text("Hath Local Network Host").newlineBold() + Text(hathLocalNetworkHostDescription.localized) - ) { - HStack { - Text("IP address:Port") - Spacer() - SettingTextField(text: $ehSetting.hathLocalNetworkHost, width: 150).focused($isFocused) - } - } - .onChange(of: shouldHideKeyboard) { _ in - isFocused = false - } - .textCase(nil) - } -} - -// MARK: OriginalImagesSection -private struct OriginalImagesSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Group { - if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { - Section("Original Images".localized) { - Toggle("Use original images", isOn: useOriginalImagesBinding) - } - .textCase(nil) - } - } - } -} - -// MARK: MultiplePageViewerSection -private struct MultiplePageViewerSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Group { - if let useMultiplePageViewerBinding = Binding($ehSetting.useMultiplePageViewer), - let multiplePageViewerStyleBinding = Binding($ehSetting.multiplePageViewerStyle), - let multiplePageViewerShowPaneBinding = Binding($ehSetting.multiplePageViewerShowThumbnailPane) - { - Section("Multi-Page Viewer".localized) { - Toggle("Use Multi-Page Viewer", isOn: useMultiplePageViewerBinding) - HStack { - Text("Display style") - Spacer() - Picker(selection: multiplePageViewerStyleBinding) { - ForEach(EhSettingMultiplePageViewerStyle.allCases) { style in - Text(style.value.localized).tag(style) - } - } label: { - Text(ehSetting.multiplePageViewerStyle?.value.localized ?? "") - } - .pickerStyle(.menu) - } - Toggle("Show thumbnail pane", isOn: multiplePageViewerShowPaneBinding) - } - .textCase(nil) - } - } - } -} - -private extension String { - var lineCount: Int { - var count = 0 - enumerateLines { _, _ in - count += 1 - } - return count - } -} -private extension Text { - func newlineBold() -> Text { - bold() + Text("\n") - } -} - -struct EhSettingView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - EhSettingView().environmentObject(Store.preview) - } - .navigationViewStyle(.stack) - } -} diff --git a/EhPanda/View/Setting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSettingView.swift index 1cec013e..fb30d510 100644 --- a/EhPanda/View/Setting/GeneralSettingView.swift +++ b/EhPanda/View/Setting/GeneralSettingView.swift @@ -6,130 +6,196 @@ // import SwiftUI -import Kingfisher -import LocalAuthentication +import FilePicker +import ComposableArchitecture -struct GeneralSettingView: View, StoreAccessor { - @EnvironmentObject var store: Store - @State private var passcodeNotSet = false - @State private var diskImageCacheSize = "0 KB" - @State private var clearDialogPresented = false +struct GeneralSettingView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let tagTranslatorLoadingState: LoadingState + private let tagTranslatorEmpty: Bool + private let tagTranslatorHasCustomTranslations: Bool + @Binding private var translatesTags: Bool + @Binding private var redirectsLinksToSelectedHost: Bool + @Binding private var detectsLinksFromClipboard: Bool + @Binding private var backgroundBlurRadius: Double + @Binding private var autoLockPolicy: AutoLockPolicy - private var isTranslatesTagsVisible: Bool { - guard let preferredLanguage = Locale.preferredLanguages.first else { return false } - let isLanguageSupported = TranslatableLanguage.allCases.map(\.languageCode).contains( - where: preferredLanguage.contains - ) - return isLanguageSupported && !settings.tagTranslator.contents.isEmpty + init( + store: Store, + tagTranslatorLoadingState: LoadingState, tagTranslatorEmpty: Bool, + tagTranslatorHasCustomTranslations: Bool, translatesTags: Binding, + redirectsLinksToSelectedHost: Binding, detectsLinksFromClipboard: Binding, + backgroundBlurRadius: Binding, autoLockPolicy: Binding + ) { + self.store = store + viewStore = ViewStore(store) + self.tagTranslatorLoadingState = tagTranslatorLoadingState + self.tagTranslatorEmpty = tagTranslatorEmpty + self.tagTranslatorHasCustomTranslations = tagTranslatorHasCustomTranslations + _translatesTags = translatesTags + _redirectsLinksToSelectedHost = redirectsLinksToSelectedHost + _detectsLinksFromClipboard = detectsLinksFromClipboard + _backgroundBlurRadius = backgroundBlurRadius + _autoLockPolicy = autoLockPolicy + } + + private var language: String { + Locale.current.localizedString(forLanguageCode: Locale.current.languageCode ?? "") + ?? R.string.localizable.generalSettingViewValueDefaultLanguageDescription() } var body: some View { Form { Section { HStack { - Text("Language") + Text(R.string.localizable.generalSettingViewTitleLanguage()) + Spacer() + Button(language) { + viewStore.send(.navigateToSystemSetting) + } + .foregroundStyle(.tint) + } + Button(R.string.localizable.generalSettingViewButtonLogs()) { + viewStore.send(.setNavigation(.logs)) + } + .foregroundColor(.primary).withArrow() + } + Section(R.string.localizable.generalSettingViewSectionTitleTagsTranslation()) { + HStack { + Text(R.string.localizable.generalSettingViewTitleTranslatesTags()) Spacer() - Button(language, action: tryNavigateToSystemSetting).foregroundStyle(.tint) + ZStack { + Image(systemSymbol: .exclamationmarkTriangleFill).foregroundStyle(.yellow) + .opacity( + translatesTags && tagTranslatorEmpty + && tagTranslatorLoadingState != .loading ? 1 : 0 + ) + ProgressView().tint(nil).opacity(tagTranslatorLoadingState == .loading ? 1 : 0) + } + Toggle("", isOn: $translatesTags).frame(width: 50) } - if isTranslatesTagsVisible { - Toggle(isOn: settingBinding.translatesTags) { - Text("Translates tags") + FilePicker( + types: [.json], allowMultiple: false, + title: R.string.localizable.generalSettingViewButtonImportCustomTranslations() + ) { urls in + if let url = urls.first { + viewStore.send(.onTranslationsFilePicked(url)) + } + } + if tagTranslatorHasCustomTranslations { + Button( + R.string.localizable.generalSettingViewButtonRemoveCustomTranslations(), + role: .destructive, action: { viewStore.send(.setNavigation(.removeCustomTranslations)) } + ) + .confirmationDialog( + message: R.string.localizable.confirmationDialogTitleRemoveCustomTranslations(), + unwrapping: viewStore.binding(\.$route), + case: /GeneralSettingState.Route.removeCustomTranslations + ) { + Button(R.string.localizable.confirmationDialogButtonRemove(), role: .destructive) { + viewStore.send(.onRemoveCustomTranslations) + } } } - NavigationLink("Logs", destination: LogsView()) } - Section("Navigation".localized) { - Toggle("Redirects links to the selected host", isOn: settingBinding.redirectsLinksToSelectedHost) - Toggle("Detects links from the clipboard", isOn: settingBinding.detectsLinksFromPasteboard) + Section(R.string.localizable.generalSettingViewSectionTitleNavigation()) { + Toggle( + R.string.localizable.generalSettingViewTitleRedirectsLinksToTheSelectedHost(), + isOn: $redirectsLinksToSelectedHost + ) + Toggle( + R.string.localizable.generalSettingViewTitleDetectsLinksFromClipboard(), + isOn: $detectsLinksFromClipboard + ) } - Section("Security".localized) { + Section(R.string.localizable.generalSettingViewSectionTitleSecurity()) { HStack { - Text("Auto-Lock") + Text(R.string.localizable.generalSettingViewTitleAutoLock()) Spacer() - Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow) - .opacity((passcodeNotSet && setting.autoLockPolicy != .never) ? 1 : 0) - Picker( - selection: settingBinding.autoLockPolicy, - label: Text(setting.autoLockPolicy.descriptionKey) - ) { + Image(systemSymbol: .exclamationmarkTriangleFill).foregroundStyle(.yellow) + .opacity((viewStore.passcodeNotSet && autoLockPolicy != .never) ? 1 : 0) + Picker(selection: $autoLockPolicy, label: Text(autoLockPolicy.value)) { ForEach(AutoLockPolicy.allCases) { policy in - Text(policy.descriptionKey).tag(policy) + Text(policy.value).tag(policy) } } .pickerStyle(.menu) } VStack(alignment: .leading) { - Text("App switcher blur") + Text(R.string.localizable.generalSettingViewTitleBackgroundBlurRadius()) HStack { - Image(systemName: "eye") - Slider(value: settingBinding.backgroundBlurRadius, in: 0...100, step: 10) - Image(systemName: "eye.slash") + Image(systemSymbol: .eye) + Slider(value: $backgroundBlurRadius, in: 0...100, step: 10) + Image(systemSymbol: .eyeSlash) } } } - Section("Cache".localized) { + Section(R.string.localizable.generalSettingViewSectionTitleCaches()) { Button { - clearDialogPresented = true + viewStore.send(.setNavigation(.clearCache)) } label: { HStack { - Text("Clear image caches") + Text(R.string.localizable.generalSettingViewButtonClearImageCaches()) Spacer() - Text(diskImageCacheSize).foregroundStyle(.tint) + Text(viewStore.diskImageCacheSize).foregroundStyle(.tint) } .foregroundColor(.primary) } + .confirmationDialog( + message: R.string.localizable.confirmationDialogTitleClear(), + unwrapping: viewStore.binding(\.$route), + case: /GeneralSettingState.Route.clearCache + ) { + Button(R.string.localizable.confirmationDialogButtonClear(), role: .destructive) { + viewStore.send(.clearWebImageCache) + } + } } } - .confirmationDialog( - "Are you sure to clear?", isPresented: $clearDialogPresented, titleVisibility: .visible - ) { - Button("Clear", role: .destructive, action: clearImageCaches) + .animation(.default, value: tagTranslatorHasCustomTranslations) + .animation(.default, value: tagTranslatorLoadingState) + .animation(.default, value: tagTranslatorEmpty) + .onAppear { + viewStore.send(.checkPasscodeSetting) + viewStore.send(.calculateWebImageDiskCache) } - .onAppear(perform: onStartTasks).navigationBarTitle("General") - } -} -private extension GeneralSettingView { - var settingBinding: Binding { - $store.appState.settings.setting - } - var language: String { - Locale.current.localizedString(forLanguageCode: Locale.current.languageCode ?? "") ?? "(null)" - } - - func onStartTasks() { - checkPasscodeExistence() - calculateDiskCachesSize() + .background(navigationLink) + .navigationTitle(R.string.localizable.generalSettingViewTitleGeneral()) } - func checkPasscodeExistence() { - var error: NSError? - guard !LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return } - passcodeNotSet = true - } - - func tryNavigateToSystemSetting() { - guard let settingURL = URL(string: UIApplication.openSettingsURLString) else { return } - UIApplication.shared.open(settingURL, options: [:]) + private var navigationLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /GeneralSettingState.Route.logs) { _ in + LogsView(store: store.scope(state: \.logsState, action: GeneralSettingAction.logs)) + } } +} - func readableUnit(bytes: I) -> String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useAll] - return formatter.string(fromByteCount: Int64(bytes)) - } - func calculateDiskCachesSize() { - KingfisherManager.shared.cache.calculateDiskStorageSize { result in - switch result { - case .success(let size): - diskImageCacheSize = readableUnit(bytes: size) - case .failure(let error): - Logger.error(error) - } +struct GeneralSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + GeneralSettingView( + store: .init( + initialState: .init(), + reducer: generalSettingReducer, + environment: GeneralSettingEnvironment( + fileClient: .live, + loggerClient: .live, + libraryClient: .live, + databaseClient: .live, + uiApplicationClient: .live, + authorizationClient: .live + ) + ), + tagTranslatorLoadingState: .idle, + tagTranslatorEmpty: false, + tagTranslatorHasCustomTranslations: false, + translatesTags: .constant(false), + redirectsLinksToSelectedHost: .constant(false), + detectsLinksFromClipboard: .constant(false), + backgroundBlurRadius: .constant(10), + autoLockPolicy: .constant(.never) + ) } } - func clearImageCaches() { - KingfisherManager.shared.cache.clearDiskCache() - PersistenceController.removeImageURLs() - calculateDiskCachesSize() - } } diff --git a/EhPanda/View/Setting/LaboratorySettingView.swift b/EhPanda/View/Setting/LaboratorySettingView.swift index bf34954b..16f6ac78 100644 --- a/EhPanda/View/Setting/LaboratorySettingView.swift +++ b/EhPanda/View/Setting/LaboratorySettingView.swift @@ -6,39 +6,39 @@ // import SwiftUI +import SFSafeSymbols -struct LaboratorySettingView: View, StoreAccessor { - @EnvironmentObject var store: Store +struct LaboratorySettingView: View { + @Binding private var bypassesSNIFiltering: Bool - private var settingBinding: Binding { - $store.appState.settings.setting + init(bypassesSNIFiltering: Binding) { + _bypassesSNIFiltering = bypassesSNIFiltering } var body: some View { ScrollView { VStack { LaboratoryCell( - isOn: settingBinding.bypassesSNIFiltering, - title: "Bypass SNI Filtering", - symbol: "theatermasks.fill", - tintColor: .purple + isOn: $bypassesSNIFiltering, + title: R.string.localizable.laboratorySettingViewTitleBypassesSNIFiltering(), + symbol: .theatermasksFill, tintColor: .purple ) } .padding() } - .navigationBarTitle("Laboratory") + .navigationTitle(R.string.localizable.laboratorySettingViewTitleLaboratory()) } } struct LaboratoryCell: View { @Binding private var isOn: Bool private let title: String - private let symbol: String + private let symbol: SFSymbol private let tintColor: Color init( isOn: Binding, title: String, - symbol: String, tintColor: Color + symbol: SFSymbol, tintColor: Color ) { _isOn = isOn self.title = title @@ -57,17 +57,25 @@ struct LaboratoryCell: View { HStack { Spacer() Group { - Image(systemName: symbol) - Text(title.localized).fontWeight(.bold) + Image(systemSymbol: symbol) + Text(title).bold() } .foregroundColor(contentColor).font(.title2) Spacer() } - .contentShape(Rectangle()).onTapGesture { - withAnimation { isOn.toggle() } - HapticUtil.generateFeedback(style: .soft) - } + .contentShape(Rectangle()).onTapGesture { isOn.toggle() } .minimumScaleFactor(0.75).padding(.vertical, 20) .background(bgColor).cornerRadius(15).lineLimit(1) + .animation(.default, value: isOn) + } +} + +struct LaboratorySettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LaboratorySettingView( + bypassesSNIFiltering: .constant(false) + ) + } } } diff --git a/EhPanda/View/Setting/LoginView.swift b/EhPanda/View/Setting/LoginView.swift deleted file mode 100644 index ff3fb529..00000000 --- a/EhPanda/View/Setting/LoginView.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// LoginView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/08/12. -// - -import SwiftUI - -struct LoginView: View, StoreAccessor { - @EnvironmentObject var store: Store - @Environment(\.dismiss) var dismissAction - - @FocusState private var focusedState: FocusedField? - @State var shouldRestoreFocus = false - @State var isLoggingIn = false - @State var username = "" - @State var password = "" - - private var isLoginButtonDisabled: Bool { - username.isEmpty || password.isEmpty - } - private var loginButtonColor: Color { - isLoggingIn ? .clear : isLoginButtonDisabled ? .primary.opacity(0.25) : .primary.opacity(0.75) - } - - // MARK: LoginView - var body: some View { - GeometryReader { proxy in - ZStack { - Group { - WaveForm(color: Color(.systemGray2).opacity(0.2), amplify: 100, isReversed: true) - WaveForm(color: Color(.systemGray).opacity(0.2), amplify: 120, isReversed: false) - } - .offset(y: proxy.size.height * 0.3).drawingGroup() - VStack(spacing: 15) { - Group { - LoginTextField( - focusedState: $focusedState, text: $username, description: "Username", isPassword: false - ) - LoginTextField( - focusedState: $focusedState, text: $password, description: "Password", isPassword: true - ) - } - .padding(.horizontal, proxy.size.width * 0.2) - Button(action: login) { - Image(systemName: "chevron.forward.circle.fill") - } - .overlay { ProgressView().tint(nil).opacity(isLoggingIn ? 1 : 0) } - .imageScale(.large).font(.largeTitle).foregroundColor(loginButtonColor) - .disabled(isLoginButtonDisabled).padding(.top, 30) - } - } - } - .toolbar(content: toolbar) - .onReceive(UIApplication.willResignActiveNotification.publisher) { _ in - guard focusedState != nil else { return } - shouldRestoreFocus = true - } - .onReceive(UIApplication.didBecomeActiveNotification.publisher) { _ in - guard shouldRestoreFocus else { return } - focusedState = .username - shouldRestoreFocus = false - } - .onSubmit { - switch focusedState { - case .username: - focusedState = .password - case .password: - focusedState = nil - login() - default: - break - } - } - .navigationTitle("Login").ignoresSafeArea() - } - // MARK: Toolbar - private func toolbar() -> some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - store.dispatch(.setSettingViewSheetState(.webviewLogin)) - } label: { - Image(systemName: "globe") - } - .disabled(setting.bypassesSNIFiltering) - } - } - - // MARK: Networking - private func login() { - guard !isLoginButtonDisabled || isLoggingIn else { return } - withAnimation { isLoggingIn = true } - HapticUtil.generateFeedback(style: .soft) - - let token = SubscriptionToken() - LoginRequest(username: username, password: password) - .publisher.receive(on: DispatchQueue.main) - .sink { _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - withAnimation { isLoggingIn = false } - guard AuthorizationUtil.didLogin else { - HapticUtil.generateNotificationFeedback(style: .error) - return - } - HapticUtil.generateNotificationFeedback(style: .success) - dismissAction.callAsFunction() - store.dispatch(.doFinishLoginTasks) - } - token.unseal() - } receiveValue: { value in - guard setting.bypassesSNIFiltering, let (_, resp) = value as? (Data, HTTPURLResponse) else { return } - - CookiesUtil.setIgneous(for: resp) - store.dispatch(.fetchIgneous) - } - .seal(in: token) - } -} - -// MARK: LoginTextField -private struct LoginTextField: View { - @Environment(\.colorScheme) private var colorScheme - private let focusedState: FocusState.Binding - @Binding private var text: String - private let description: String - private let isPassword: Bool - - private var backgroundColor: Color { - colorScheme == .light ? Color(.systemGray6) : Color(.systemGray5) - } - - init( - focusedState: FocusState.Binding, - text: Binding, description: String, isPassword: Bool - ) { - self.focusedState = focusedState - _text = text - self.description = description - self.isPassword = isPassword - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(description.localized).font(.caption).foregroundStyle(.secondary) - Group { - if isPassword { - SecureField("", text: $text) - } else { - TextField("", text: $text) - } - } - .focused(focusedState.projectedValue, equals: isPassword ? .password : .username) - .textContentType(isPassword ? .password : .username).submitLabel(isPassword ? .done : .next) - .textInputAutocapitalization(.none).disableAutocorrection(true).keyboardType(.asciiCapable) - .padding(10).background(backgroundColor.opacity(0.75).cornerRadius(8)) - } - } -} - -struct LoginView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - LoginView() - } - } -} - -// MARK: Definition -private enum FocusedField { - case username - case password -} diff --git a/EhPanda/View/Setting/LogsView.swift b/EhPanda/View/Setting/LogsView.swift deleted file mode 100644 index aa63e62a..00000000 --- a/EhPanda/View/Setting/LogsView.swift +++ /dev/null @@ -1,172 +0,0 @@ -// -// LogsView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/06/27. -// - -import SwiftUI - -struct LogsView: View, StoreAccessor { - @EnvironmentObject var store: Store - @State private var logs = [Log]() - - var body: some View { - ZStack { - List(logs) { log in - NavigationLink(destination: LogView(log: log)) { - LogCell(log: log, isLatest: log == logs.first) - } - .swipeActions { swipeActions(log: log) } - } - ErrorView(error: .notFound, retryAction: nil) - .opacity(logs.isEmpty ? 1 : 0) - } - .onAppear(perform: fetchLogsIfNeeded) - .navigationBarTitle("Logs") - .toolbar(content: toolbar) - } - - private func swipeActions(log: Log) -> some View { - Button { - tryDeleteLog(name: log.fileName) - } label: { - Image(systemName: "trash") - } - .tint(.red) - } - private func toolbar() -> some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: tryExportLog) { - Image(systemName: "folder.badge.gearshape") - } - } - } -} - -// MARK: Private Methods -private extension LogsView { - func tryDeleteLog(name: String) { - guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(name) else { return } - - try? FileManager.default.removeItem(at: fileURL) - if !FileManager.default.fileExists(atPath: fileURL.path) { - logs = logs.filter({ $0.fileName != name }) - } - } - - func tryExportLog() { - guard let dirPath = FileUtil.logsDirectoryURL?.path, - let dirURL = URL(string: "shareddocuments://" + dirPath) - else { return } - - UIApplication.shared.open(dirURL, options: [:], completionHandler: nil) - } - - func fetchLogs() { - guard let path = FileUtil.logsDirectoryURL?.path, - let enumerator = FileManager.default.enumerator(atPath: path), - let fileNames = (enumerator.allObjects as? [String])? - .filter({ $0.contains(Defaults.FilePath.ehpandaLog) }) - else { return } - - let logs: [Log] = fileNames.compactMap { name in - guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(name), - let content = try? String(contentsOf: fileURL) - else { return nil } - - return Log( - fileName: name, contents: content - .components(separatedBy: "\n") - .filter({ !$0.isEmpty }) - ) - } - .sorted() - self.logs = logs - } - func fetchLogsIfNeeded() { - guard logs.isEmpty else { return } - fetchLogs() - } -} - -// MARK: LogCell -private struct LogCell: View { - private let log: Log - private let isLatest: Bool - - private var dateRangeString: String { - parseDate(string: log.contents.first) - + " - " + parseDate(string: log.contents.last) - } - - init(log: Log, isLatest: Bool) { - self.log = log - self.isLatest = isLatest - } - - var body: some View { - VStack(spacing: 5) { - HStack { - Text(log.fileName).font(.callout) - Spacer() - HStack(spacing: 2) { - Image(systemName: "checkmark.circle").foregroundColor(.green) - Text("Latest").foregroundColor(.secondary) - } - .opacity(isLatest ? 1 : 0) - .font(.caption) - } - HStack { - Text(dateRangeString).bold() - Spacer() - Text("\(log.contents.count) records") - } - .foregroundColor(.secondary) - .font(.caption2).lineLimit(1) - } - .padding() - } - - private func parseDate(string: String?) -> String { - guard let string = string, - let range = string.range(of: " ") - else { return "" } - - return String(string[.. { - $store.appState.settings.setting + init( + readingDirection: Binding, prefetchLimit: Binding, + enablesLandscape: Binding, contentDividerHeight: Binding, + maximumScaleFactor: Binding, doubleTapScaleFactor: Binding + ) { + _readingDirection = readingDirection + _prefetchLimit = prefetchLimit + _enablesLandscape = enablesLandscape + _contentDividerHeight = contentDividerHeight + _maximumScaleFactor = maximumScaleFactor + _doubleTapScaleFactor = doubleTapScaleFactor } var body: some View { Form { Section { HStack { - Text("Direction") + Text(R.string.localizable.readingSettingViewTitleDirection()) Spacer() Picker( - selection: settingBinding.readingDirection, - label: Text(setting.readingDirection.rawValue), + selection: $readingDirection, + label: Text(readingDirection.value), content: { ForEach(ReadingDirection.allCases) { - Text($0.rawValue.localized).tag($0) + Text($0.value).tag($0) } } ) .pickerStyle(.menu) } HStack { - Text("Preload limit") + Text(R.string.localizable.readingSettingViewTitlePreloadLimit()) Spacer() Picker( - selection: settingBinding.prefetchLimit, - label: Text("\(setting.prefetchLimit) pages"), + selection: $prefetchLimit, + label: Text(R.string.localizable.commonValuePages("\(prefetchLimit)")), content: { ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in - Text("\(value) pages").tag(value) + Text(R.string.localizable.commonValuePages("\(value)")).tag(value) } } ) .pickerStyle(.menu) } if !DeviceUtil.isPad { - Toggle("Prefers landscape", isOn: settingBinding.prefersLandscape) + Toggle(R.string.localizable.readingSettingViewTitleEnablesLandscape(), isOn: $enablesLandscape) } } - Section("Appearance".localized) { + Section(R.string.localizable.readingSettingViewSectionTitleAppearance()) { HStack { - Text("Separator height") + Text(R.string.localizable.readingSettingViewTitleSeparatorHeight()) Spacer() Picker( - selection: settingBinding.contentDividerHeight, - label: Text("\(Int(setting.contentDividerHeight))pt"), + selection: $contentDividerHeight, + label: Text("\(Int(contentDividerHeight))pt"), content: { ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in - Text("\(value)" + "pt").tag(Double(value)) + Text("\(value)pt").tag(Double(value)) } } ) .pickerStyle(.menu) } - .disabled(setting.readingDirection != .vertical) + .disabled(readingDirection != .vertical) ScaleFactorRow( - scaleFactor: settingBinding.maximumScaleFactor, - labelContent: "Maximum scale factor", + scaleFactor: $maximumScaleFactor, + labelContent: R.string.localizable.readingSettingViewTitleMaximumScaleFactor(), minFactor: 1.5, maxFactor: 10 ) ScaleFactorRow( - scaleFactor: settingBinding.doubleTapScaleFactor, - labelContent: "Double tap scale factor", + scaleFactor: $doubleTapScaleFactor, + labelContent: R.string.localizable.readingSettingViewTitleDoubleTapScaleFactor(), minFactor: 1.5, maxFactor: 5 ) } } - .navigationBarTitle("Reading") + .navigationTitle(R.string.localizable.readingSettingViewTitleReading()) } } -// MARK: ScaleFactorRow private struct ScaleFactorRow: View { @Binding private var scaleFactor: Double private let labelContent: String @@ -101,15 +115,15 @@ private struct ScaleFactorRow: View { var body: some View { VStack { HStack { - Text(labelContent.localized) + Text(labelContent) Spacer() - Text(scaleFactor.roundedString() + "x").foregroundStyle(.tint) + Text("\(scaleFactor.roundedString())x").foregroundStyle(.tint) } Slider( value: $scaleFactor, in: minFactor...maxFactor, step: 0.5, - minimumValueLabel: Text(minFactor.roundedString() + "x") + minimumValueLabel: Text("\(minFactor.roundedString())x") .fontWeight(.medium).font(.callout), - maximumValueLabel: Text(maxFactor.roundedString() + "x") + maximumValueLabel: Text("\(maxFactor.roundedString())x") .fontWeight(.medium).font(.callout), label: EmptyView.init ) @@ -118,18 +132,6 @@ private struct ScaleFactorRow: View { } } -struct ReadingSettingView_Previews: PreviewProvider { - static var previews: some View { - let store = Store.preview - store.appState.settings.setting = Setting() - - return ReadingSettingView() - .environmentObject(store) - .preferredColorScheme(.dark) - } -} - -// MARK: Definition private extension Double { func roundedString() -> String { roundedString(with: 1) @@ -139,3 +141,18 @@ private extension Double { String(format: "%.\(places)f", self) } } + +struct ReadingSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ReadingSettingView( + readingDirection: .constant(.vertical), + prefetchLimit: .constant(10), + enablesLandscape: .constant(false), + contentDividerHeight: .constant(0), + maximumScaleFactor: .constant(3), + doubleTapScaleFactor: .constant(2) + ) + } + } +} diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index 1bf9ae64..df2e1cca 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -6,71 +6,108 @@ // import SwiftUI +import SFSafeSymbols +import ComposableArchitecture -struct SettingView: View, StoreAccessor { - @EnvironmentObject var store: Store +struct SettingView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let blurRadius: Double + + init(store: Store, blurRadius: Double) { + self.store = store + viewStore = ViewStore(store) + self.blurRadius = blurRadius + } // MARK: SettingView var body: some View { NavigationView { ScrollView { VStack(spacing: 0) { - SettingRow( - symbolName: "person.fill", text: "Account", - destination: AccountSettingView() - ) - SettingRow( - symbolName: "switch.2", text: "General", - destination: GeneralSettingView() - ) - SettingRow( - symbolName: "circle.righthalf.fill", text: "Appearance", - destination: AppearanceSettingView() - ) - SettingRow( - symbolName: "newspaper.fill", text: "Reading", - destination: ReadingSettingView() - ) - SettingRow( - symbolName: "testtube.2", text: "Laboratory", - destination: LaboratorySettingView() - ) - SettingRow( - symbolName: "p.circle.fill", text: "About EhPanda", - destination: EhPandaView() - ) + ForEach(SettingState.Route.allCases) { route in + SettingRow(rowType: route) { + viewStore.send(.setNavigation($0)) + } + } } .padding(.vertical, 40).padding(.horizontal) } - .navigationBarTitle("Setting") - .sheet(item: $store.appState.environment.settingViewSheetState, content: sheet) + .background(navigationLinks) + .navigationTitle(R.string.localizable.settingViewTitleSetting()) } } - private func sheet(item: SettingViewSheetState) -> some View { - Group { - switch item { - case .webviewLogin: - WebView(url: Defaults.URL.webLogin.safeURL()) - case .webviewConfig: - WebView(url: Defaults.URL.ehConfig().safeURL()) - case .webviewMyTags: - WebView(url: Defaults.URL.ehMyTags().safeURL()) - } +} + +// MARK: NavigationLinks +private extension SettingView { + @ViewBuilder var navigationLinks: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.account) { _ in + AccountSettingView( + store: store.scope(state: \.accountSettingState, action: SettingAction.account), + galleryHost: viewStore.binding(\.$setting.galleryHost), + showsNewDawnGreeting: viewStore.binding(\.$setting.showsNewDawnGreeting), + bypassesSNIFiltering: viewStore.setting.bypassesSNIFiltering, + blurRadius: blurRadius + ) + .tint(viewStore.setting.accentColor) + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.general) { _ in + GeneralSettingView( + store: store.scope(state: \.generalSettingState, action: SettingAction.general), + tagTranslatorLoadingState: viewStore.tagTranslatorLoadingState, + tagTranslatorEmpty: viewStore.tagTranslator.contents.isEmpty, + tagTranslatorHasCustomTranslations: viewStore.tagTranslator.hasCustomTranslations, + translatesTags: viewStore.binding(\.$setting.translatesTags), + redirectsLinksToSelectedHost: viewStore.binding(\.$setting.redirectsLinksToSelectedHost), + detectsLinksFromClipboard: viewStore.binding(\.$setting.detectsLinksFromClipboard), + backgroundBlurRadius: viewStore.binding(\.$setting.backgroundBlurRadius), + autoLockPolicy: viewStore.binding(\.$setting.autoLockPolicy) + ) + .tint(viewStore.setting.accentColor) + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.appearance) { _ in + AppearanceSettingView( + store: store.scope(state: \.appearanceSettingState, action: SettingAction.appearance), + preferredColorScheme: viewStore.binding(\.$setting.preferredColorScheme), + accentColor: viewStore.binding(\.$setting.accentColor), + appIconType: viewStore.binding(\.$setting.appIconType), + listDisplayMode: viewStore.binding(\.$setting.listDisplayMode), + showsTagsInList: viewStore.binding(\.$setting.showsTagsInList), + listTagsNumberMaximum: viewStore.binding(\.$setting.listTagsNumberMaximum) + ) + .tint(viewStore.setting.accentColor) + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.reading) { _ in + ReadingSettingView( + readingDirection: viewStore.binding(\.$setting.readingDirection), + prefetchLimit: viewStore.binding(\.$setting.prefetchLimit), + enablesLandscape: viewStore.binding(\.$setting.enablesLandscape), + contentDividerHeight: viewStore.binding(\.$setting.contentDividerHeight), + maximumScaleFactor: viewStore.binding(\.$setting.maximumScaleFactor), + doubleTapScaleFactor: viewStore.binding(\.$setting.doubleTapScaleFactor) + ) + .tint(viewStore.setting.accentColor) + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.laboratory) { _ in + LaboratorySettingView( + bypassesSNIFiltering: viewStore.binding(\.$setting.bypassesSNIFiltering) + ) + .tint(viewStore.setting.accentColor) + } + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.ehpanda) { _ in + EhPandaView().tint(viewStore.setting.accentColor) } - .blur(radius: environment.blurRadius) - .allowsHitTesting(environment.isAppUnlocked) } } // MARK: SettingRow -private struct SettingRow: View { +private struct SettingRow: View { @Environment(\.colorScheme) private var colorScheme @State private var isPressing = false - @State private var isActive = false - private let symbolName: String - private let text: String - private let destination: Destination + private let rowType: SettingState.Route + private let tapAction: (SettingState.Route) -> Void private var color: Color { colorScheme == .light ? Color(.darkGray) : Color(.lightGray) @@ -79,27 +116,23 @@ private struct SettingRow: View { isPressing ? color.opacity(0.1) : .clear } - init(symbolName: String, text: String, destination: Destination) { - self.symbolName = symbolName - self.text = text - self.destination = destination + init(rowType: SettingState.Route, tapAction: @escaping (SettingState.Route) -> Void) { + self.rowType = rowType + self.tapAction = tapAction } var body: some View { HStack { - Image(systemName: symbolName) + Image(systemSymbol: rowType.symbol) .font(.largeTitle).foregroundColor(color) .padding(.trailing, 20).frame(width: 45) - Text(text.localized).fontWeight(.medium) + Text(rowType.value).fontWeight(.medium) .font(.title3).foregroundColor(color) Spacer() } - .background { - NavigationLink("", destination: destination, isActive: $isActive) - } .contentShape(Rectangle()).padding(.vertical, 10) .padding(.horizontal, 20).background(backgroundColor) - .cornerRadius(10).onTapGesture { isActive.toggle() } + .cornerRadius(10).onTapGesture { tapAction(rowType) } .onLongPressGesture( minimumDuration: .infinity, maximumDistance: 50, pressing: { isPressing = $0 }, perform: {} @@ -108,16 +141,64 @@ private struct SettingRow: View { } // MARK: Definition -enum SettingViewSheetState: Identifiable { - var id: Int { hashValue } - - case webviewLogin - case webviewConfig - case webviewMyTags +extension SettingState.Route { + var value: String { + switch self { + case .account: + return R.string.localizable.enumSettingStateRouteValueAccount() + case .general: + return R.string.localizable.enumSettingStateRouteValueGeneral() + case .appearance: + return R.string.localizable.enumSettingStateRouteValueAppearance() + case .reading: + return R.string.localizable.enumSettingStateRouteValueReading() + case .laboratory: + return R.string.localizable.enumSettingStateRouteValueLaboratory() + case .ehpanda: + return R.string.localizable.enumSettingStateRouteValueEhPanda() + } + } + var symbol: SFSymbol { + switch self { + case .account: + return .personFill + case .general: + return .switch2 + case .appearance: + return .circleRighthalfFilled + case .reading: + return .newspaperFill + case .laboratory: + return .testtube2 + case .ehpanda: + return .pCircleFill + } + } } struct SettingView_Previews: PreviewProvider { static var previews: some View { - SettingView().environmentObject(Store.preview) + SettingView( + store: .init( + initialState: .init(), + reducer: settingReducer, + environment: SettingEnvironment( + dfClient: .live, + fileClient: .live, + deviceClient: .live, + loggerClient: .live, + hapticClient: .live, + libraryClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + userDefaultsClient: .live, + uiApplicationClient: .live, + authorizationClient: .live + ) + ), + blurRadius: 0 + ) } } diff --git a/EhPanda/View/Setting/Support/EhSettingView.swift b/EhPanda/View/Setting/Support/EhSettingView.swift new file mode 100644 index 00000000..e89b706e --- /dev/null +++ b/EhPanda/View/Setting/Support/EhSettingView.swift @@ -0,0 +1,1073 @@ +// +// EhSettingView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/08/07. +// + +import SwiftUI +import ComposableArchitecture + +struct EhSettingView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let bypassesSNIFiltering: Bool + private let blurRadius: Double + + init(store: Store, bypassesSNIFiltering: Bool, blurRadius: Double) { + self.store = store + viewStore = ViewStore(store) + self.bypassesSNIFiltering = bypassesSNIFiltering + self.blurRadius = blurRadius + } + + // MARK: EhSettingView + var body: some View { + ZStack { + // workaround: Stay if-else approach + if viewStore.loadingState == .loading || viewStore.submittingState == .loading { + LoadingView().tint(nil) + } else if case .failed(let error) = viewStore.loadingState { + ErrorView(error: error, action: { viewStore.send(.fetchEhSetting) }).tint(nil) + } + // Using `Binding.init` will crash the app + else if let ehSetting = Binding(unwrapping: viewStore.binding(\.$ehSetting)), + let ehProfile = Binding(unwrapping: viewStore.binding(\.$ehProfile)) + { + form(ehSetting: ehSetting, ehProfile: ehProfile) + .transition(.opacity.animation(.default)) + } + } + .onAppear { + if viewStore.ehSetting == nil { + viewStore.send(.fetchEhSetting) + } + } + .onDisappear { + if let profileSet = viewStore.ehSetting?.ehpandaProfile?.value { + viewStore.send(.setDefaultProfile(profileSet)) + } + } + .sheet(unwrapping: viewStore.binding(\.$route), case: /EhSettingState.Route.webView) { route in + WebView(url: route.wrappedValue) + .autoBlur(radius: blurRadius) + } + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.ehSettingViewTitleHostSetting(AppUtil.galleryHost.rawValue)) + } + // MARK: Form + private func form(ehSetting: Binding, ehProfile: Binding) -> some View { + Form { + Group { + EhProfileSection( + route: viewStore.binding(\.$route), + ehSetting: ehSetting, ehProfile: ehProfile, + editingProfileName: viewStore.binding(\.$editingProfileName), + deleteAction: { + if let value = viewStore.ehProfile?.value { + viewStore.send(.performAction(.delete, nil, value)) + } + }, + deleteDialogAction: { viewStore.send(.setNavigation(.deleteProfile)) }, + performEhProfileAction: { viewStore.send(.performAction($0, $1, $2)) } + ) + ImageLoadSettingsSection(ehSetting: ehSetting) + ImageSizeSettingsSection(ehSetting: ehSetting) + GalleryNameDisplaySection(ehSetting: ehSetting) + ArchiverSettingsSection(ehSetting: ehSetting) + FrontPageSettingsSection(ehSetting: ehSetting) + FavoritesSection(ehSetting: ehSetting) + RatingsSection(ehSetting: ehSetting) + TagNamespacesSection(ehSetting: ehSetting) + TagFilteringThresholdSection(ehSetting: ehSetting) + } + Group { + TagWatchingThresholdSection(ehSetting: ehSetting) + ExcludedLanguagesSection(ehSetting: ehSetting) + ExcludedUploadersSection(ehSetting: ehSetting) + SearchResultCountSection(ehSetting: ehSetting) + ThumbnailSettingsSection(ehSetting: ehSetting) + ThumbnailScalingSection(ehSetting: ehSetting) + ViewportOverrideSection(ehSetting: ehSetting) + GalleryCommentsSection(ehSetting: ehSetting) + GalleryTagsSection(ehSetting: ehSetting) + GalleryPageNumberingSection(ehSetting: ehSetting) + } + Group { +// HathLocalNetworkHostSection(ehSetting: ehSetting) + OriginalImagesSection(ehSetting: ehSetting) + MultiplePageViewerSection(ehSetting: ehSetting) + } + } + } + // MARK: Toolbar + private func toolbar() -> some ToolbarContent { + Group { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + viewStore.send(.setNavigation(.webView(Defaults.URL.uConfig))) + } label: { + Image(systemSymbol: .globe) + } + .disabled(bypassesSNIFiltering) + } + ToolbarItem(placement: .confirmationAction) { + Button { + viewStore.send(.submitChanges) + } label: { + Image(systemSymbol: .icloudAndArrowUp) + } + .disabled(viewStore.ehSetting == nil) + } + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Button(R.string.localizable.ehSettingViewToolbarItemButtonDone()) { + viewStore.send(.setKeyboardHidden) + } + } + } + } + } +} + +// MARK: EhProfileSection +private struct EhProfileSection: View { + @Binding private var route: EhSettingState.Route? + @Binding private var ehSetting: EhSetting + @Binding private var ehProfile: EhProfile + @Binding private var editingProfileName: String + private let deleteAction: () -> 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(R.string.localizable.ehSettingViewSectionTitleProfileSettings()) { + HStack { + Text(R.string.localizable.ehSettingViewTitleSelectedProfile()) + Spacer() + Picker(selection: $ehProfile) { + ForEach(ehSetting.ehProfiles) { ehProfile in + Text(ehProfile.name).tag(ehProfile) + } + } label: { + Text(ehProfile.name) + } + .pickerStyle(.menu) + } + if !ehProfile.isDefault { + Button(R.string.localizable.ehSettingViewButtonSetAsDefault()) { + performEhProfileAction(.default, nil, ehProfile.value) + } + Button( + R.string.localizable.ehSettingViewButtonDeleteProfile(), + role: .destructive, action: deleteDialogAction + ) + .confirmationDialog( + message: R.string.localizable.confirmationDialogTitleDelete(), + unwrapping: $route, case: /EhSettingState.Route.deleteProfile + ) { + Button( + R.string.localizable.confirmationDialogButtonDelete(), + role: .destructive, action: deleteAction + ) + } + } + } + .onChange(of: ehProfile) { + performEhProfileAction(nil, nil, $0.value) + } + .textCase(nil) + Section { + SettingTextField( + text: $editingProfileName, width: nil, alignment: .leading, background: .clear + ) + .focused($isFocused) + Button(R.string.localizable.ehSettingViewButtonRename()) { + performEhProfileAction(.rename, editingProfileName, ehProfile.value) + } + .disabled(isFocused) + if ehSetting.ehProfiles.count < 10 { + Button(R.string.localizable.ehSettingViewButtonCreateNew()) { + 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( + header: Text(R.string.localizable.ehSettingViewSectionTitleImageLoadSettings()), + footer: Text(ehSetting.loadThroughHathSetting.description) + ) { + Text(R.string.localizable.ehSettingViewTitleLoadImagesThroughTheHathNetwork()) + Picker(selection: $ehSetting.loadThroughHathSetting) { + ForEach(ehSetting.capableLoadThroughHathSettings) { setting in + Text(setting.value).tag(setting) + } + } label: { + Text(ehSetting.loadThroughHathSetting.value) + } + .pickerStyle(.menu) + } + .textCase(nil) + Section( + R.string.localizable.ehSettingViewDescriptionBrowsingCountry( + ehSetting.literalBrowsingCountry + ) + .localizedKey + ) { + Picker(R.string.localizable.ehSettingViewTitleBrowsingCountry(), selection: $ehSetting.browsingCountry) { + ForEach(EhSetting.BrowsingCountry.allCases) { country in + Text(country.name).tag(country) + .foregroundColor(country == ehSetting.browsingCountry ? .accentColor : .primary) + } + } + } + .textCase(nil) + } +} + +// MARK: ImageSizeSettingsSection +private struct ImageSizeSettingsSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleImageSizeSettings()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionImageResolution()) + ) { + HStack { + Text(R.string.localizable.ehSettingViewTitleImageResolution()) + Spacer() + Picker(selection: $ehSetting.imageResolution) { + ForEach(ehSetting.capableImageResolutions) { setting in + Text(setting.value).tag(setting) + } + } label: { + Text(ehSetting.imageResolution.value) + } + .pickerStyle(.menu) + } + } + .textCase(nil) + Section(R.string.localizable.ehSettingViewDescriptionImageSize()) { + Text(R.string.localizable.ehSettingViewTitleImageSize()) + ValuePicker( + title: R.string.localizable.ehSettingViewTitleHorizontal(), + value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" + ) + ValuePicker( + title: R.string.localizable.ehSettingViewTitleVertical(), + value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" + ) + } + .textCase(nil) + } +} + +// MARK: GalleryNameDisplaySection +private struct GalleryNameDisplaySection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleGalleryNameDisplay()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionGalleryName()) + ) { + HStack { + Text(R.string.localizable.ehSettingViewTitleGalleryName()) + Spacer() + Picker(selection: $ehSetting.galleryName) { + ForEach(EhSetting.GalleryName.allCases) { name in + Text(name.value).tag(name) + } + } label: { + Text(ehSetting.galleryName.value) + } + .pickerStyle(.menu) + } + } + .textCase(nil) + } +} + +// MARK: ArchiverSettingsSection +private struct ArchiverSettingsSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleArchiverSettings()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionArchiverBehavior()) + ) { + Text(R.string.localizable.ehSettingViewTitleArchiverBehavior()) + Picker(selection: $ehSetting.archiverBehavior) { + ForEach(EhSetting.ArchiverBehavior.allCases) { behavior in + Text(behavior.value).tag(behavior) + } + } label: { + Text(ehSetting.archiverBehavior.value) + } + .pickerStyle(.menu) + } + .textCase(nil) + } +} + +// 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( + header: Text(R.string.localizable.ehSettingViewSectionTitleFrontPageSettings()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionDisplayMode()) + ) { + HStack { + Text(R.string.localizable.ehSettingViewTitleDisplayMode()) + Spacer() + Picker(selection: $ehSetting.displayMode) { + ForEach(EhSetting.DisplayMode.allCases) { mode in + Text(mode.value).tag(mode) + } + } label: { + Text(ehSetting.displayMode.value) + } + .pickerStyle(.menu) + } + } + .textCase(nil) + Section(R.string.localizable.ehSettingViewDescriptionGalleryCategory()) { + CategoryView(bindings: categoryBindings) + } + .textCase(nil) + } +} + +// 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( + header: Text(R.string.localizable.ehSettingViewSectionTitleFavorites()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionFavoriteCategories()) + ) { + 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) + } + } + .textCase(nil) + Section(R.string.localizable.ehSettingViewDescriptionFavoritesSortOrder()) { + HStack { + Text(R.string.localizable.ehSettingViewTitleFavoritesSortOrder()) + Spacer() + Picker(selection: $ehSetting.favoritesSortOrder) { + ForEach(EhSetting.FavoritesSortOrder.allCases) { order in + Text(order.value).tag(order) + } + } label: { + Text(ehSetting.favoritesSortOrder.value) + } + .pickerStyle(.menu) + } + } + .textCase(nil) + } +} + +// MARK: RatingsSection +private struct RatingsSection: View { + @Binding private var ehSetting: EhSetting + @FocusState var isFocused + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleRatings()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionRatingsColor()) + ) { + HStack { + Text(R.string.localizable.ehSettingViewTitleRatingsColor()) + Spacer() + SettingTextField( + text: $ehSetting.ratingsColor, promptText: R.string.localizable + .ehSettingViewPromtRatingsColor(), width: 80 + ) + .focused($isFocused) + } + } + .textCase(nil) + } +} + +// MARK: TagNamespacesSection +private struct TagNamespacesSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + private var tuples: [(String, Binding)] { + TagCategory.allCases.dropLast().enumerated().map { index, category in + (category.value, $ehSetting.excludedNamespaces[index]) + } + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleTagsNamespaces()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionTagsNamespaces()) + ) { + ExcludeView(tuples: tuples) + } + .textCase(nil) + } +} + +private struct ExcludeView: View { + private let tuples: [(String, Binding)] + + init(tuples: [(String, Binding)]) { + self.tuples = tuples + } + + private let gridItems = [ + GridItem(.adaptive( + minimum: DeviceUtil.isPadWidth ? 100 : 80, maximum: 100 + )) + ] + + var body: some View { + LazyVGrid(columns: gridItems) { + ForEach(tuples, id: \.0) { text, isExcluded in + ZStack { + Text(text).bold().opacity(isExcluded.wrappedValue ? 0 : 1) + ZStack { + Text(text) + let width = (CGFloat(text.count) * 8) + 8 + let line = Rectangle().frame(width: width, height: 1) + VStack(spacing: 2) { + line + line + } + } + .foregroundColor(.red).opacity(isExcluded.wrappedValue ? 1 : 0) + } + .onTapGesture { + HapticUtil.generateFeedback(style: .soft) + withAnimation { isExcluded.wrappedValue.toggle() } + } + } + } + .padding(.vertical) + } +} + +// MARK: TagFilteringThresholdSection +private struct TagFilteringThresholdSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleTagFilteringThreshold()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionTagFilteringThreshold()) + ) { + ValuePicker( + title: R.string.localizable.ehSettingViewTitleTagFilteringThreshold(), + value: $ehSetting.tagFilteringThreshold, range: -9999...0 + ) + } + .textCase(nil) + } +} + +// MARK: TagWatchingThresholdSection +private struct TagWatchingThresholdSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleTagWatchingThreshold()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionTagWatchingThreshold()) + ) { + ValuePicker( + title: R.string.localizable.ehSettingViewTitleTagWatchingThreshold(), + value: $ehSetting.tagWatchingThreshold, range: 0...9999 + ) + } + .textCase(nil) + } +} + +// 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( + header: Text(R.string.localizable.ehSettingViewSectionTitleExcludedLanguages()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionExcludedLanguages()) + ) { + 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) { index in + ExcludeRow( + title: languages[index], + bindings: rowBindings(index: index), + isFirstRow: index == 0 + ) + } + } + .textCase(nil) + } +} + +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 { + HStack { + Text(title).lineLimit(1).font(.subheadline).fixedSize() + Spacer() + } + .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() } + HapticUtil.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( + header: Text(R.string.localizable.ehSettingViewSectionTitleExcludedUploaders()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionExcludedUploaders()), + footer: Text( + R.string.localizable.ehSettingViewDescriptionExcludedUploadersCount( + "\(ehSetting.excludedUploaders.lineCount)", "\(1000)" + ) + .localizedKey + ) + ) { + TextEditor(text: $ehSetting.excludedUploaders).textInputAutocapitalization(.none) + .frame(maxHeight: DeviceUtil.windowH * 0.3).disableAutocorrection(true).focused($isFocused) + } + .textCase(nil) + } +} + +// MARK: SearchResultCountSection +private struct SearchResultCountSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleSearchResultCount()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionResultCount()) + ) { + HStack { + Text(R.string.localizable.ehSettingViewTitleResultCount()) + Spacer() + Picker(selection: $ehSetting.searchResultCount) { + ForEach(ehSetting.capableSearchResultCounts) { count in + Text(String(count.value)).tag(count) + } + } label: { + Text(String(ehSetting.searchResultCount.value)) + } + .pickerStyle(.menu) + } + } + .textCase(nil) + } +} + +// MARK: ThumbnailSettingsSection +private struct ThumbnailSettingsSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleThumbnailSettings()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionThumbnailLoadTiming()), + footer: Text(ehSetting.thumbnailLoadTiming.description) + ) { + HStack { + Text(R.string.localizable.ehSettingViewTitleThumbnailLoadTiming()) + Spacer() + Picker(selection: $ehSetting.thumbnailLoadTiming) { + ForEach(EhSetting.ThumbnailLoadTiming.allCases) { timing in + Text(timing.value).tag(timing) + } + } label: { + Text(ehSetting.thumbnailLoadTiming.value) + } + .pickerStyle(.menu) + } + } + .textCase(nil) + Section(R.string.localizable.ehSettingViewDescriptionThumbnailConfiguration()) { + HStack { + Text(R.string.localizable.ehSettingViewTitleThumbnailSize()) + Spacer() + Picker(selection: $ehSetting.thumbnailConfigSize) { + ForEach(ehSetting.capableThumbnailConfigSizes) { size in + Text(size.value).tag(size) + } + } label: { + Text(ehSetting.thumbnailConfigSize.value) + } + .pickerStyle(.segmented).frame(width: 200) + } + HStack { + Text(R.string.localizable.ehSettingViewTitleThumbnailRowCount()) + Spacer() + Picker(selection: $ehSetting.thumbnailConfigRows) { + ForEach(ehSetting.capableThumbnailConfigRowCounts) { row in + Text(row.value).tag(row) + } + } label: { + Text(ehSetting.capableThumbnailConfigRowCount.value) + } + .pickerStyle(.segmented).frame(width: 200) + } + } + .textCase(nil) + } +} + +// MARK: ThumbnailScalingSection +private struct ThumbnailScalingSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleThumbnailScaling()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionScaleFactor()) + ) { + ValuePicker( + title: R.string.localizable.ehSettingViewTitleScaleFactor(), + value: $ehSetting.thumbnailScaleFactor, range: 75...150, unit: "%" + ) + } + .textCase(nil) + } +} + +// MARK: ViewportOverrideSection +private struct ViewportOverrideSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleViewportOverride()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionVirtualWidth()) + ) { + ValuePicker( + title: R.string.localizable.ehSettingViewTitleVirtualWidth(), + value: $ehSetting.viewportVirtualWidth, range: 0...9999, unit: "px" + ) + } + .textCase(nil) + } +} + +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 { + VStack { + HStack { + Text(title) + Spacer() + Text(String(Int(value)) + unit).foregroundStyle(.tint) + } + } + Slider( + value: $value, in: range, step: 1, + minimumValueLabel: Text(String(Int(range.lowerBound)) + unit).fontWeight(.medium).font(.callout), + maximumValueLabel: Text(String(Int(range.upperBound)) + unit).fontWeight(.medium).font(.callout), + label: EmptyView.init + ) + } +} + +// MARK: GalleryCommentsSection +private struct GalleryCommentsSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section(R.string.localizable.ehSettingViewSectionTitleGalleryComments()) { + HStack { + Text(R.string.localizable.ehSettingViewTitleCommentsSortOrder()) + Spacer() + Picker(selection: $ehSetting.commentsSortOrder) { + ForEach(EhSetting.CommentsSortOrder.allCases) { order in + Text(order.value).tag(order) + } + } label: { + Text(ehSetting.commentsSortOrder.value) + } + .pickerStyle(.menu) + } + HStack { + Text(R.string.localizable.ehSettingViewTitleCommentsVotesShowTiming()) + Spacer() + Picker(selection: $ehSetting.commentVotesShowTiming) { + ForEach(EhSetting.CommentVotesShowTiming.allCases) { timing in + Text(timing.value).tag(timing) + } + } label: { + Text(ehSetting.commentVotesShowTiming.value) + } + .pickerStyle(.menu) + } + } + .textCase(nil) + } +} + +// MARK: GalleryTagsSection +private struct GalleryTagsSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section(R.string.localizable.ehSettingViewSectionTitleGalleryTags()) { + HStack { + Text(R.string.localizable.ehSettingViewTitleTagsSortOrder()) + Spacer() + Picker(selection: $ehSetting.tagsSortOrder) { + ForEach(EhSetting.TagsSortOrder.allCases) { order in + Text(order.value).tag(order) + } + } label: { + Text(ehSetting.tagsSortOrder.value) + } + .pickerStyle(.menu) + } + } + .textCase(nil) + } +} + +// MARK: GalleryPageNumberingSection +private struct GalleryPageNumberingSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section(R.string.localizable.ehSettingViewSectionTitleGalleryPageNumbering()) { + Toggle( + R.string.localizable.ehSettingViewTitleShowGalleryPageNumbers(), + isOn: $ehSetting.galleryShowPageNumbers + ) + } + .textCase(nil) + } +} + +/* +// MARK: HathLocalNetworkHostSection +private struct HathLocalNetworkHostSection: View { + @Binding private var ehSetting: EhSetting + @FocusState var isFocused + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Section( + header: Text(R.string.localizable.ehSettingViewSectionTitleHathLocalNetworkHost()).newlineBold() + + Text(R.string.localizable.ehSettingViewDescriptionIpAddressPort()) + ) { + HStack { + Text(R.string.localizable.ehSettingViewTitleIpAddressPort()) + Spacer() + SettingTextField(text: $ehSetting.hathLocalNetworkHost, width: 150).focused($isFocused) + } + } + .textCase(nil) + } +} + */ + +// MARK: OriginalImagesSection +private struct OriginalImagesSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Group { + if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { + Section(R.string.localizable.ehSettingViewSectionTitleOriginalImages()) { + Toggle( + R.string.localizable.ehSettingViewTitleUseOriginalImages(), + isOn: useOriginalImagesBinding + ) + } + .textCase(nil) + } + } + } +} + +// MARK: MultiplePageViewerSection +private struct MultiplePageViewerSection: View { + @Binding private var ehSetting: EhSetting + + init(ehSetting: Binding) { + _ehSetting = ehSetting + } + + var body: some View { + Group { + if let useMultiplePageViewerBinding = Binding($ehSetting.useMultiplePageViewer), + let multiplePageViewerStyleBinding = Binding($ehSetting.multiplePageViewerStyle), + let multiplePageViewerShowPaneBinding = Binding($ehSetting.multiplePageViewerShowThumbnailPane) + { + Section(R.string.localizable.ehSettingViewSectionTitleMultiPageViewer()) { + Toggle( + R.string.localizable.ehSettingViewTitleUseMultiPageViewer(), + isOn: useMultiplePageViewerBinding + ) + HStack { + Text(R.string.localizable.ehSettingViewTitleDisplayStyle()) + Spacer() + Picker(selection: multiplePageViewerStyleBinding) { + ForEach(EhSetting.MultiplePageViewerStyle.allCases) { style in + Text(style.value).tag(style) + } + } label: { + Text(ehSetting.multiplePageViewerStyle?.value ?? "") + } + .pickerStyle(.menu) + } + Toggle( + R.string.localizable.ehSettingViewTitleShowThumbnailPane(), + isOn: multiplePageViewerShowPaneBinding + ) + } + .textCase(nil) + } + } + } +} + +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") + } +} + +struct EhSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EhSettingView( + store: .init( + initialState: .init(), + reducer: ehSettingReducer, + environment: EhSettingEnvironment( + hapticClient: .live, + cookiesClient: .live, + uiApplicationClient: .live + ) + ), + bypassesSNIFiltering: false, + blurRadius: 0 + ) + } + } +} diff --git a/EhPanda/View/Setting/Support/LoginView.swift b/EhPanda/View/Setting/Support/LoginView.swift new file mode 100644 index 00000000..9eb761ad --- /dev/null +++ b/EhPanda/View/Setting/Support/LoginView.swift @@ -0,0 +1,140 @@ +// +// LoginView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/08/12. +// + +import SwiftUI +import ComposableArchitecture + +struct LoginView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + private let bypassesSNIFiltering: Bool + private let blurRadius: Double + + @FocusState private var focusedField: LoginState.FocusedField? + + init(store: Store, bypassesSNIFiltering: Bool, blurRadius: Double) { + self.store = store + viewStore = ViewStore(store) + self.bypassesSNIFiltering = bypassesSNIFiltering + self.blurRadius = blurRadius + } + + // MARK: LoginView + var body: some View { + GeometryReader { proxy in + ZStack { + Group { + WaveForm(color: Color(.systemGray2).opacity(0.2), amplify: 100, isReversed: true) + WaveForm(color: Color(.systemGray).opacity(0.2), amplify: 120, isReversed: false) + } + .offset(y: proxy.size.height * 0.3).drawingGroup() + VStack(spacing: 15) { + Group { + LoginTextField( + focusedField: $focusedField, text: viewStore.binding(\.$username), + description: R.string.localizable.loginViewTitleUsername(), isPassword: false + ) + LoginTextField( + focusedField: $focusedField, text: viewStore.binding(\.$password), + description: R.string.localizable.loginViewTitlePassword(), isPassword: true + ) + } + .padding(.horizontal, proxy.size.width * 0.2) + Button { + viewStore.send(.login) + } label: { + Image(systemSymbol: .chevronForwardCircleFill) + } + .overlay { ProgressView().tint(nil).opacity(viewStore.loginState == .loading ? 1 : 0) } + .imageScale(.large).font(.largeTitle).foregroundColor(viewStore.loginButtonColor) + .disabled(viewStore.loginButtonDisabled).padding(.top, 30) + } + } + } + .synchronize(viewStore.binding(\.$focusedField), $focusedField) + .sheet(unwrapping: viewStore.binding(\.$route), case: /LoginState.Route.webView) { route in + WebView(url: route.wrappedValue) { + viewStore.send(.loginDone(.success(nil))) + } + .autoBlur(radius: blurRadius) + } + .onSubmit { viewStore.send(.onTextFieldSubmitted) } + .toolbar(content: toolbar) + .navigationTitle(R.string.localizable.loginViewTitleLogin()) + .ignoresSafeArea() + } + // MARK: Toolbar + private func toolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + viewStore.send(.setNavigation(.webView(Defaults.URL.webLogin))) + } label: { + Image(systemSymbol: .globe) + } + .disabled(bypassesSNIFiltering) + } + } +} + +// MARK: LoginTextField +private struct LoginTextField: View { + @Environment(\.colorScheme) private var colorScheme + private let focusedField: FocusState.Binding + @Binding private var text: String + private let description: String + private let isPassword: Bool + + private var backgroundColor: Color { + colorScheme == .light ? Color(.systemGray6) : Color(.systemGray5) + } + + init( + focusedField: FocusState.Binding, + text: Binding, description: String, isPassword: Bool + ) { + self.focusedField = focusedField + _text = text + self.description = description + self.isPassword = isPassword + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(description).font(.caption).foregroundStyle(.secondary) + Group { + if isPassword { + SecureField("", text: $text) + } else { + TextField("", text: $text) + } + } + .focused(focusedField.projectedValue, equals: isPassword ? .password : .username) + .textContentType(isPassword ? .password : .username).submitLabel(isPassword ? .done : .next) + .textInputAutocapitalization(.none).disableAutocorrection(true).keyboardType(.asciiCapable) + .padding(10).background(backgroundColor.opacity(0.75).cornerRadius(8)) + } + } +} + +struct LoginView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LoginView( + store: .init( + initialState: .init(), + reducer: loginReducer, + environment: LoginEnvironment( + hapticClient: .live, + cookiesClient: .live + ) + ), + bypassesSNIFiltering: false, + blurRadius: 0 + ) + } + } +} diff --git a/EhPanda/View/Setting/Support/LogsView.swift b/EhPanda/View/Setting/Support/LogsView.swift new file mode 100644 index 00000000..8b66cd5b --- /dev/null +++ b/EhPanda/View/Setting/Support/LogsView.swift @@ -0,0 +1,177 @@ +// +// LogsView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/06/27. +// + +import SwiftUI +import ComposableArchitecture + +struct LogsView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + + init(store: Store) { + self.store = store + viewStore = ViewStore(store) + } + + var body: some View { + ZStack { + List(viewStore.logs) { log in + Button { + viewStore.send(.setNavigation(.log(log))) + } label: { + LogCell(log: log, isLatest: log == viewStore.logs.first) + } + .swipeActions { + Button { + viewStore.send(.deleteLog(log.fileName)) + } label: { + Image(systemSymbol: .trash) + } + .tint(.red) + } + .foregroundColor(.primary) + } + .opacity(viewStore.logs.isEmpty ? 0 : 1) + LoadingView().opacity(viewStore.loadingState == .loading && viewStore.logs.isEmpty ? 1 : 0) + let error = (/LoadingState.failed).extract(from: viewStore.loadingState) + ErrorView(error: error ?? .notFound) { + viewStore.send(.fetchLogs) + } + .opacity(error != nil && viewStore.logs.isEmpty ? 1 : 0) + } + .onAppear { + if viewStore.logs.isEmpty { + DispatchQueue.main.async { + viewStore.send(.fetchLogs) + } + } + } + .toolbar(content: toolbar) + .background(navigationLink) + .navigationTitle(R.string.localizable.logsViewTitleLogs()) + } + + private var navigationLink: some View { + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /LogsState.Route.log) { route in + LogView(log: route.wrappedValue) + } + } + private func toolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + viewStore.send(.navigateToFileApp) + } label: { + Image(systemSymbol: .folderBadgeGearshape) + } + } + } +} + +// MARK: LogCell +private struct LogCell: View { + private let log: Log + private let isLatest: Bool + + private var dateRangeString: String { + parseDate(string: log.contents.first) + + " - " + parseDate(string: log.contents.last) + } + + init(log: Log, isLatest: Bool) { + self.log = log + self.isLatest = isLatest + } + + var body: some View { + VStack(spacing: 5) { + HStack { + Text(log.fileName).font(.callout) + Spacer() + HStack(spacing: 2) { + Image(systemSymbol: .checkmarkCircle) + .foregroundColor(.green) + Text(R.string.localizable.logsViewTitleLatest()) + } + .opacity(isLatest ? 0.6 : 0) + .font(.caption) + } + HStack { + Text(dateRangeString).bold() + Spacer() + Text(R.string.localizable.commonValueRecords("\(log.contents.count)")) + } + .foregroundColor(.secondary) + .font(.caption2).lineLimit(1) + } + .padding() + } + + private func parseDate(string: String?) -> String { + guard let string = string, + let range = string.range(of: " ") + else { return "" } + + return String(string[.. Bool { + lhs.fileName < rhs.fileName + } + + var id: String { fileName } + let fileName: String + let contents: [String] +} + +struct LogsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LogsView( + store: .init( + initialState: .init(), + reducer: logsReducer, + environment: .init( + fileClient: .live, + uiApplicationClient: .live + ) + ) + ) + } + } +} diff --git a/EhPanda/View/Setting/WebView.swift b/EhPanda/View/Setting/Support/WebView.swift similarity index 75% rename from EhPanda/View/Setting/WebView.swift rename to EhPanda/View/Setting/Support/WebView.swift index b99f3dd8..79e883ac 100644 --- a/EhPanda/View/Setting/WebView.swift +++ b/EhPanda/View/Setting/Support/WebView.swift @@ -9,13 +9,12 @@ import WebKit import SwiftUI struct WebView: UIViewControllerRepresentable { - static let loginURLString = "https://forums.e-hentai.org/index.php?act=Login" - - @EnvironmentObject private var store: Store private let url: URL + private let loginDoneAction: (() -> Void)? - init(url: URL) { + init(url: URL, loginDoneAction: (() -> Void)? = nil) { self.url = url + self.loginDoneAction = loginDoneAction } final class Coodinator: NSObject, WKNavigationDelegate, WKUIDelegate { @@ -26,8 +25,12 @@ struct WebView: UIViewControllerRepresentable { } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - guard parent.url.absoluteString == WebView.loginURLString, - webView.url?.absoluteString.contains("CODE=01") == true + 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 + }) else { return } webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in @@ -35,10 +38,7 @@ struct WebView: UIViewControllerRepresentable { } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard AuthorizationUtil.didLogin else { return } - let store = self?.parent.store - store?.dispatch(.setSettingViewSheetState(nil)) - store?.dispatch(.doFinishLoginTasks) + self?.parent.loginDoneAction?() } } diff --git a/EhPanda/View/Support/Components/ActivityView.swift b/EhPanda/View/Support/Components/ActivityView.swift new file mode 100644 index 00000000..79a7e742 --- /dev/null +++ b/EhPanda/View/Support/Components/ActivityView.swift @@ -0,0 +1,21 @@ +// +// ActivityView.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/19. +// + +import SwiftUI + +struct ActivityView: UIViewControllerRepresentable { + private var activityItems: [Any] + + init(activityItems: [Any]) { + self.activityItems = activityItems + } + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/EhPanda/View/Support/Components/AlertView.swift b/EhPanda/View/Support/Components/AlertView.swift new file mode 100644 index 00000000..498160a9 --- /dev/null +++ b/EhPanda/View/Support/Components/AlertView.swift @@ -0,0 +1,158 @@ +// +// AlertView.swift +// EhPanda +// +// Created by 荒木辰造 on R 2/12/27. +// + +import SwiftUI +import SFSafeSymbols + +struct LoadingView: View { + private let title: String + + init(title: String = R.string.localizable.loadingViewTitleLoading()) { + self.title = title + } + + var body: some View { + ProgressView(title) + } +} + +struct FetchMoreFooter: View { + private let loadingState: LoadingState + private let retryAction: (() -> Void)? + + init(loadingState: LoadingState, retryAction: (() -> Void)?) { + self.loadingState = loadingState + self.retryAction = retryAction + } + + var body: some View { + HStack(alignment: .center) { + Spacer() + ZStack { + ProgressView().opacity(loadingState == .loading ? 1 : 0) + Button { + retryAction?() + } label: { + Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + .foregroundStyle(.red).imageScale(.large) + } + .opacity(![.idle, .loading].contains(loadingState) ? 1 : 0) + } + Spacer() + } + .frame(height: 50) + } +} + +struct NotLoginView: View { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + var body: some View { + AlertView( + symbol: .personCropCircleBadgeQuestionmarkFill, + message: R.string.localizable.notLoginViewTitleNeedLogin() + ) { + AlertViewButton(title: R.string.localizable.notLoginViewButtonLogin(), action: action) + } + } +} + +struct ErrorView: View { + private let error: AppError + private let buttonTitle: String + private let action: (() -> Void)? + + init( + error: AppError, buttonTitle: String = + R.string.localizable.errorViewButtonRetry(), + action: (() -> Void)? = nil + ) { + self.error = error + self.buttonTitle = buttonTitle + self.action = action + } + + var body: some View { + AlertView(symbol: error.symbol, message: error.alertText) { + if let action = action { + AlertViewButton(title: buttonTitle, action: action) + } + } + } +} + +struct AlertView: View { + @Environment(\.colorScheme) private var colorScheme + private let symbol: SFSymbol + private let message: String + private let actions: Content + + init(symbol: SFSymbol, message: String, @ViewBuilder actions: () -> Content) { + self.symbol = symbol + self.message = message + self.actions = actions() + } + + var body: some View { + VStack { + Image(systemSymbol: symbol).font(.system(size: 50)).padding(.bottom, 15) + Text(message).multilineTextAlignment(.center).foregroundStyle(.gray) + .font(.headline).padding(.bottom, 5) + actions + } + .frame(maxWidth: DeviceUtil.windowW * 0.8) + } +} + +struct AlertViewButton: View { + private let title: String + private let action: () -> Void + + init(title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + Button(action: action) { + Text(title).foregroundColor(.primary.opacity(0.7)).textCase(.uppercase) + } + .buttonStyle(.bordered).buttonBorderShape(.capsule) + } +} + +struct PageJumpView: View { + @Environment(\.colorScheme) private var colorScheme + @Binding private var inputText: String + private var isFocused: FocusState.Binding + private let pageNumber: PageNumber + + init(inputText: Binding, isFocused: FocusState.Binding, pageNumber: PageNumber) { + _inputText = inputText + self.isFocused = isFocused + self.pageNumber = pageNumber + } + + var body: some View { + VStack { + Text(R.string.localizable.jumpPageViewTitleJumpPage()).bold() + HStack { + let opacity = colorScheme == .light ? 0.15 : 0.1 + TextField(inputText, text: $inputText).multilineTextAlignment(.center).keyboardType(.numberPad) + .padding(.horizontal, 10).padding(.vertical, 5).background(Color.gray.opacity(opacity)) + .cornerRadius(5).frame(width: 75).focused(isFocused.projectedValue) + Text("-") + Text("\(pageNumber.maximum + 1)") + } + .lineLimit(1) + } + } +} diff --git a/EhPanda/View/Tools/Category.swift b/EhPanda/View/Support/Components/CategoryView.swift similarity index 92% rename from EhPanda/View/Tools/Category.swift rename to EhPanda/View/Support/Components/CategoryView.swift index 8ce68e94..33a77ea4 100644 --- a/EhPanda/View/Tools/Category.swift +++ b/EhPanda/View/Support/Components/CategoryView.swift @@ -1,5 +1,5 @@ // -// Category.swift +// CategoryView.swift // EhPanda // // Created by 荒木辰造 on R 3/08/02. @@ -30,7 +30,7 @@ struct CategoryLabel: View { } var body: some View { - Text(text).fontWeight(.bold).lineLimit(1).font(font).foregroundStyle(.white) + Text(text).font(font.bold()).lineLimit(1).foregroundStyle(.white) .padding(insets).background( Rectangle().foregroundStyle(color).cornerRadius(cornerRadius, corners: corners) ) @@ -79,7 +79,7 @@ private struct CategoryCell: View { ZStack { Rectangle() .foregroundColor(isFiltered ? category.color.opacity(0.3) : category.color) - Text(category.rawValue.localized).fontWeight(.bold).foregroundStyle(.white) + Text(category.value).bold().foregroundStyle(.white) .padding(.vertical, 5).lineLimit(1) } .onTapGesture { diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift new file mode 100644 index 00000000..0ab70303 --- /dev/null +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -0,0 +1,82 @@ +// +// GalleryCardCell.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/13. +// + +import SwiftUI +import Colorful +import Kingfisher +import UIImageColors + +struct GalleryCardCell: View { + @Environment(\.colorScheme) private var colorScheme + + private let currentID: String + private let colors: [Color] + private let webImageSuccessAction: (RetrieveImageResult) -> Void + + private let gallery: Gallery + + private let animation: Animation = + .interpolatingSpring(stiffness: 50, damping: 1).speed(0.2) + + init( + gallery: Gallery, currentID: String, colors: [Color], + webImageSuccessAction: @escaping (RetrieveImageResult) -> Void + ) { + self.gallery = gallery + self.currentID = currentID + self.colors = colors + self.webImageSuccessAction = webImageSuccessAction + } + + private var animated: Bool { + guard colorScheme == .dark else { return false } + return gallery.gid == currentID + } + private var title: String { + let trimmedTitle = gallery.trimmedTitle + guard !DeviceUtil.isPad, trimmedTitle.count > 20 else { + return gallery.title + } + return trimmedTitle + } + + 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) + .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) + Spacer() + RatingView(rating: gallery.rating).foregroundColor(.yellow) + } + .padding(.leading, 15) + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + } + .frame(width: Defaults.FrameSize.cardCellWidth).cornerRadius(15) + } +} + +struct GalleryCardCell_Previews: PreviewProvider { + static var previews: some View { + let gallery = Gallery.preview + GalleryCardCell( + gallery: gallery, currentID: gallery.gid, + colors: ColorfulView.defaultColorList, + webImageSuccessAction: { _ in } + ) + .previewLayout(.fixed(width: 300, height: 206)).padding() + } +} diff --git a/EhPanda/View/Tools/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift similarity index 78% rename from EhPanda/View/Tools/GalleryDetailCell.swift rename to EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index 7e3ba417..054f732a 100644 --- a/EhPanda/View/Tools/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -23,13 +23,13 @@ struct GalleryDetailCell: View { var body: some View { HStack(spacing: 10) { - KFImage(URL(string: gallery.coverURL)) + KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } .defaultModifier().scaledToFit().frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH) VStack(alignment: .leading) { Text(gallery.title).lineLimit(1).font(.headline).foregroundStyle(.primary) Text(gallery.uploader ?? "").lineLimit(1).font(.subheadline).foregroundStyle(.secondary) - if setting.showsSummaryRowTags, !tags.isEmpty { + if setting.showsTagsInList, !tags.isEmpty { TagCloudView( tag: GalleryTag(content: tags), font: .caption2, textColor: .secondary, backgroundColor: tagColor, @@ -41,16 +41,16 @@ struct GalleryDetailCell: View { RatingView(rating: gallery.rating).font(.caption).foregroundStyle(.yellow) Spacer() HStack(spacing: 10) { - Text(gallery.language?.rawValue.localized ?? "") + Text(gallery.language?.value ?? "") HStack(spacing: 2) { - Image(systemName: "photo.on.rectangle.angled") + Image(systemSymbol: .photoOnRectangleAngled) Text(String(gallery.pageCount)) } } .lineLimit(1).font(.footnote).foregroundStyle(.secondary).minimumScaleFactor(0.75) } HStack(alignment: .bottom) { - CategoryLabel(text: category, color: gallery.color) + CategoryLabel(text: gallery.category.value, color: gallery.color) Spacer() Text(gallery.formattedDateString).lineLimit(1).font(.footnote) .foregroundStyle(.secondary).minimumScaleFactor(0.75) @@ -59,17 +59,15 @@ struct GalleryDetailCell: View { } .drawingGroup() } - .padding(.vertical, setting.showsSummaryRowTags ? 5 : 0).padding(.leading, -10).padding(.trailing, -5) + .padding(.vertical, setting.showsTagsInList ? 5 : 0).padding(.leading, -10).padding(.trailing, -5) } } private extension GalleryDetailCell { - var category: String { - gallery.category.rawValue.localized - } var tags: [String] { - guard setting.summaryRowTagsMaximum > 0 else { return gallery.tags } - return Array(gallery.tags.prefix(setting.summaryRowTagsMaximum)) + let maximum = setting.listTagsNumberMaximum + guard maximum > 0 else { return gallery.tagStrings } + return Array(gallery.tagStrings.prefix(min(gallery.tagStrings.count, maximum))) } var tagColor: Color { colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) @@ -78,6 +76,6 @@ private extension GalleryDetailCell { struct GalleryDetailCell_Previews: PreviewProvider { static var previews: some View { - GalleryDetailCell(gallery: .preview, setting: Setting()).preferredColorScheme(.dark) + GalleryDetailCell(gallery: .preview, setting: Setting()) } } diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift new file mode 100644 index 00000000..f8b07585 --- /dev/null +++ b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -0,0 +1,43 @@ +// +// GalleryHistoryCell.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import SwiftUI +import Kingfisher + +struct GalleryHistoryCell: View { + private let gallery: Gallery + + init(gallery: Gallery) { + self.gallery = gallery + } + + var body: some View { + HStack(spacing: 20) { + KFImage(gallery.coverURL) + .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() + .scaledToFill().frame(width: Defaults.ImageSize.rowW * 0.75, height: Defaults.ImageSize.rowH * 0.75) + .cornerRadius(2) + VStack(alignment: .leading) { + Text(gallery.trimmedTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) + if let uploader = gallery.uploader { + Text(uploader).foregroundColor(.secondary).lineLimit(1) + } + Spacer() + RatingView(rating: gallery.rating).foregroundColor(.primary) + } + .font(.caption) + Spacer() + } + .frame(width: Defaults.ImageSize.rowW * 3, height: Defaults.ImageSize.rowH * 0.75) + } +} + +struct GalleryHistoryCell_Previews: PreviewProvider { + static var previews: some View { + GalleryHistoryCell(gallery: .preview) + } +} diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift new file mode 100644 index 00000000..b0e95618 --- /dev/null +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -0,0 +1,45 @@ +// +// GalleryRankingCell.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/14. +// + +import SwiftUI +import Kingfisher + +struct GalleryRankingCell: View { + private let gallery: Gallery + private let ranking: Int + + init(gallery: Gallery, ranking: Int) { + self.gallery = gallery + self.ranking = ranking + } + + var body: some View { + HStack { + KFImage(gallery.coverURL) + .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() + .scaledToFill().frame(width: Defaults.ImageSize.rowW * 0.75, height: Defaults.ImageSize.rowH * 0.75) + .cornerRadius(2) + Text(String(ranking)).fontWeight(.medium).font(.title2).padding(.horizontal) + VStack(alignment: .leading) { + Text(gallery.trimmedTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) + if let uploader = gallery.uploader { + Text(uploader).foregroundColor(.secondary).lineLimit(1) + } + } + .font(.caption) + Spacer() + } + } +} + +struct GalleryRankingCell_Previews: PreviewProvider { + static var previews: some View { + GalleryRankingCell(gallery: .preview, ranking: 1) + .previewLayout(.fixed(width: 300, height: 100)) + .preferredColorScheme(.dark) + } +} diff --git a/EhPanda/View/Tools/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift similarity index 84% rename from EhPanda/View/Tools/GalleryThumbnailCell.swift rename to EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index 10c7d8c7..1bf97a99 100644 --- a/EhPanda/View/Tools/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -23,7 +23,7 @@ struct GalleryThumbnailCell: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - KFImage(URL(string: gallery.coverURL)) + KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } .imageModifier(WebtoonModifier( minAspect: Defaults.ImageSize.webtoonMinAspect, @@ -34,7 +34,7 @@ struct GalleryThumbnailCell: View { HStack { Spacer() CategoryLabel( - text: category, color: gallery.color, + text: gallery.category.value, color: gallery.color, insets: .init(top: 3, leading: 6, bottom: 3, trailing: 6), cornerRadius: 15, corners: .bottomLeft ) @@ -43,8 +43,8 @@ struct GalleryThumbnailCell: View { } } VStack(alignment: .leading) { - Text(gallery.title).bold().font(.callout).lineLimit(3) - if setting.showsSummaryRowTags, !gallery.tags.isEmpty { + Text(gallery.title).font(.callout.bold()).lineLimit(3) + if setting.showsTagsInList, !gallery.tagStrings.isEmpty { TagCloudView( tag: GalleryTag(content: tags), font: .caption2, textColor: .secondary, backgroundColor: tagColor, @@ -58,7 +58,7 @@ struct GalleryThumbnailCell: View { HStack(spacing: 10) { if !DeviceUtil.isSEWidth { HStack(spacing: 2) { - Image(systemName: "photo.on.rectangle.angled") + Image(systemSymbol: .photoOnRectangleAngled) Text(String(gallery.pageCount)) } } @@ -74,9 +74,6 @@ struct GalleryThumbnailCell: View { } private extension GalleryThumbnailCell { - var category: String { - gallery.category.rawValue.localized - } var backgroundColor: Color { colorScheme == .light ? Color(.systemGray6) : Color(.systemGray5) } @@ -84,8 +81,9 @@ private extension GalleryThumbnailCell { colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) } var tags: [String] { - guard setting.summaryRowTagsMaximum > 0 else { return gallery.tags } - return Array(gallery.tags.prefix(setting.summaryRowTagsMaximum)) + let maximum = setting.listTagsNumberMaximum + guard maximum > 0 else { return gallery.tagStrings } + return Array(gallery.tagStrings.prefix(min(gallery.tagStrings.count, maximum))) } } diff --git a/EhPanda/View/Support/Components/GenericList.swift b/EhPanda/View/Support/Components/GenericList.swift new file mode 100644 index 00000000..ec139c34 --- /dev/null +++ b/EhPanda/View/Support/Components/GenericList.swift @@ -0,0 +1,204 @@ +// +// GenericList.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/07/25. +// + +import SwiftUI +import WaterfallGrid +import ComposableArchitecture + +struct GenericList: View { + private let galleries: [Gallery] + private let setting: Setting + private let pageNumber: PageNumber? + private let loadingState: LoadingState + private let footerLoadingState: LoadingState + private let fetchAction: (() -> Void)? + private let fetchMoreAction: (() -> Void)? + private let navigateAction: ((String) -> Void)? + private let translateAction: ((String) -> String)? + + init( + galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, + loadingState: LoadingState, footerLoadingState: LoadingState, + fetchAction: (() -> Void)? = nil, + fetchMoreAction: (() -> Void)? = nil, + navigateAction: ((String) -> Void)? = nil, + translateAction: ((String) -> String)? = nil + ) { + self.galleries = galleries + self.setting = setting + self.pageNumber = pageNumber + self.loadingState = loadingState + self.footerLoadingState = footerLoadingState + self.fetchAction = fetchAction + self.fetchMoreAction = fetchMoreAction + self.navigateAction = navigateAction + self.translateAction = translateAction + } + + var body: some View { + ZStack { + VStack(spacing: 0) { + switch setting.listDisplayMode { + case .detail: + DetailList( + galleries: galleries, setting: setting, pageNumber: pageNumber, + footerLoadingState: footerLoadingState, fetchMoreAction: fetchMoreAction, + navigateAction: navigateAction, translateAction: translateAction + ) + case .thumbnail: + WaterfallList( + galleries: galleries, setting: setting, pageNumber: pageNumber, + footerLoadingState: footerLoadingState, fetchMoreAction: fetchMoreAction, + navigateAction: navigateAction, translateAction: translateAction + ) + } + } + .opacity(loadingState == .idle ? 1 : 0).zIndex(2) + LoadingView().opacity(loadingState == .loading ? 1 : 0).zIndex(0) + let error = (/LoadingState.failed).extract(from: loadingState) + ErrorView(error: error ?? .unknown, action: fetchAction) + .opacity([.idle, .loading].contains(loadingState) ? 0 : 1).zIndex(1) + } + .animation(.default, value: loadingState) + .animation(.default, value: galleries) + .refreshable { fetchAction?() } + } +} + +// MARK: DetailList +private struct DetailList: View { + private let galleries: [Gallery] + private let setting: Setting + private let pageNumber: PageNumber? + private let footerLoadingState: LoadingState + private let fetchMoreAction: (() -> Void)? + private let navigateAction: ((String) -> Void)? + private let translateAction: ((String) -> String)? + + init( + galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, + footerLoadingState: LoadingState, + fetchMoreAction: (() -> Void)?, + navigateAction: ((String) -> Void)? = nil, + translateAction: ((String) -> String)? = nil + ) { + self.galleries = galleries + self.setting = setting + self.pageNumber = pageNumber + self.footerLoadingState = footerLoadingState + self.fetchMoreAction = fetchMoreAction + self.navigateAction = navigateAction + self.translateAction = translateAction + } + + private func shouldShowFooter(gallery: Gallery) -> Bool { + guard let pageNumber = pageNumber else { return false } + + let isLastGallery = gallery == galleries.last + let isLoadingStateIdle = footerLoadingState == .idle + let isPageNumberValid = pageNumber.current + 1 <= pageNumber.maximum + + return isLastGallery && !isLoadingStateIdle && isPageNumberValid + } + + var body: some View { + List(galleries) { gallery in + Button { + navigateAction?(gallery.id) + } label: { + GalleryDetailCell(gallery: gallery, setting: setting, translateAction: translateAction) + } + .foregroundColor(.primary) + .onAppear { + if gallery == galleries.last { + fetchMoreAction?() + } + } + if shouldShowFooter(gallery: gallery) { + FetchMoreFooter(loadingState: footerLoadingState, retryAction: fetchMoreAction) + } + } + } +} + +// MARK: WaterfallList +private struct WaterfallList: View { + private let galleries: [Gallery] + private let setting: Setting + private let pageNumber: PageNumber? + private let footerLoadingState: LoadingState + private let fetchMoreAction: (() -> Void)? + private let navigateAction: ((String) -> Void)? + private let translateAction: ((String) -> String)? + + private var columnsInPortrait: Int { + DeviceUtil.isPadWidth ? 4 : 2 + } + private var columnsInLandscape: Int { + DeviceUtil.isPadWidth ? 5 : 2 + } + + private var shouldShowFooter: Bool { + guard let pageNumber = pageNumber else { return false } + + let isPageNumberValid = pageNumber.current + 1 <= pageNumber.maximum + let isLoadingStateIdle = footerLoadingState == .idle + + return !isLoadingStateIdle && isPageNumberValid + } + + init( + galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, + footerLoadingState: LoadingState, + fetchMoreAction: (() -> Void)?, + navigateAction: ((String) -> Void)? = nil, + translateAction: ((String) -> String)? = nil + ) { + self.galleries = galleries + self.setting = setting + self.pageNumber = pageNumber + self.footerLoadingState = footerLoadingState + self.fetchMoreAction = fetchMoreAction + self.navigateAction = navigateAction + self.translateAction = translateAction + } + + var body: some View { + List { + WaterfallGrid(galleries) { gallery in + Button { + navigateAction?(gallery.id) + } label: { + GalleryThumbnailCell(gallery: gallery, setting: setting, translateAction: translateAction) + } + .foregroundColor(.primary) + } + .gridStyle( + columnsInPortrait: columnsInPortrait, columnsInLandscape: columnsInLandscape, + spacing: 15, animation: nil + ) + if !shouldShowFooter { + Button { + fetchMoreAction?() + } label: { + HStack { + Spacer() + Image(systemSymbol: .chevronDown) + Spacer() + } + } + .foregroundStyle(.tint) + } else { + FetchMoreFooter( + loadingState: footerLoadingState, + retryAction: fetchMoreAction + ) + } + } + .listStyle(.plain) + } +} diff --git a/EhPanda/View/Tools/Placeholder.swift b/EhPanda/View/Support/Components/Placeholder.swift similarity index 86% rename from EhPanda/View/Tools/Placeholder.swift rename to EhPanda/View/Support/Components/Placeholder.swift index ce8c61f0..8bba279b 100644 --- a/EhPanda/View/Tools/Placeholder.swift +++ b/EhPanda/View/Support/Components/Placeholder.swift @@ -8,6 +8,7 @@ import SwiftUI struct Placeholder: View { + @Environment(\.inSheet) private var inSheet private let style: PlaceholderStyle init(style: PlaceholderStyle) { @@ -18,7 +19,7 @@ struct Placeholder: View { switch style { case .activity(let ratio, let cornerRadius): ZStack { - Color(.systemGray5) + Color(inSheet ? .systemGray4 : .systemGray5) ProgressView() } .aspectRatio(ratio, contentMode: .fill).cornerRadius(cornerRadius) @@ -26,7 +27,7 @@ struct Placeholder: View { ZStack { backgroundColor VStack { - Text(String(pageNumber)).fontWeight(.bold).font(.largeTitle) + Text(String(pageNumber)).font(.largeTitle.bold()) .foregroundColor(.gray).padding(.bottom, 30) ProgressView(progress).progressViewStyle(.plainLinear) .frame(width: DeviceUtil.absWindowW * (isDualPage ? 0.25 : 0.5)) diff --git a/EhPanda/View/Tools/SettingTextField.swift b/EhPanda/View/Support/Components/SettingTextField.swift similarity index 100% rename from EhPanda/View/Tools/SettingTextField.swift rename to EhPanda/View/Support/Components/SettingTextField.swift diff --git a/EhPanda/View/Support/Components/SubSection.swift b/EhPanda/View/Support/Components/SubSection.swift new file mode 100644 index 00000000..f1101c1e --- /dev/null +++ b/EhPanda/View/Support/Components/SubSection.swift @@ -0,0 +1,69 @@ +// +// SubSection.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/18. +// + +import SwiftUI + +struct SubSection: View { + private let title: String + private let showAll: Bool + private let tint: Color? + private let isLoading: Bool? + private let reloadAction: (() -> Void)? + private let showAllAction: () -> Void + private let content: Content + + init( + title: String, showAll: Bool = true, + tint: Color? = nil, isLoading: Bool? = nil, + reloadAction: (() -> Void)? = nil, + showAllAction: @escaping () -> Void = {}, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.showAll = showAll + self.tint = tint + self.isLoading = isLoading + self.reloadAction = reloadAction + self.showAllAction = showAllAction + self.content = content() + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Button { + reloadAction?() + HapticUtil.generateFeedback(style: .soft) + } label: { + HStack(spacing: 10) { + Text(title).font(.title3.bold()) + ProgressView() + .opacity(isLoading == true ? 1 : 0) + .animation(.default, value: isLoading) + } + } + .allowsHitTesting(reloadAction != nil) + .foregroundColor(.primary) + Spacer() + Button(action: showAllAction) { + Text(R.string.localizable.subSectionButtonShowAll()).font(.subheadline) + } + .tint(tint).opacity(showAll ? 1 : 0) + } + .padding(.horizontal) + content + } + } +} + +struct SubSection_Previews: PreviewProvider { + static var previews: some View { + SubSection(title: "Title") { + Text("Content") + } + } +} diff --git a/EhPanda/View/Tools/TagCloudView.swift b/EhPanda/View/Support/Components/TagCloudView.swift similarity index 95% rename from EhPanda/View/Tools/TagCloudView.swift rename to EhPanda/View/Support/Components/TagCloudView.swift index 3ea22f0d..143f052b 100644 --- a/EhPanda/View/Tools/TagCloudView.swift +++ b/EhPanda/View/Support/Components/TagCloudView.swift @@ -79,8 +79,7 @@ private extension TagCloudView { .background(viewHeightReader(binding: $totalHeight)) } - @ViewBuilder - func item(for text: String) -> some View { + @ViewBuilder func item(for text: String) -> some View { let (rippedText, wrappedHex) = Parser.parseWrappedHex(string: text) let containsHex = wrappedHex != nil let textColor = containsHex ? .white : textColor @@ -88,7 +87,7 @@ private extension TagCloudView { let displayText: String = translatedText == nil ? rippedText : translatedText.forceUnwrapped let backgroundColor = containsHex ? Color(hex: wrappedHex.forceUnwrapped) : backgroundColor - Text(displayText).fontWeight(.bold).lineLimit(1).font(font).foregroundColor(textColor) + Text(displayText).font(font.bold()).lineLimit(1).foregroundColor(textColor) .padding(.vertical, paddingV).padding(.horizontal, paddingH).background(backgroundColor) .cornerRadius(5).onTapGesture { onTapAction( diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift new file mode 100644 index 00000000..7845052c --- /dev/null +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -0,0 +1,195 @@ +// +// ToolbarItems.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import SwiftUI + +struct CustomToolbarItem: ToolbarContent { + private let placement: ToolbarItemPlacement + private let tint: Color? + private let disabled: Bool + private let content: Content + + init(placement: ToolbarItemPlacement = .navigationBarTrailing, + tint: Color? = nil, disabled: Bool = false, + @ViewBuilder content: () -> Content + ) { + self.placement = placement + self.tint = tint + self.disabled = disabled + self.content = content() + } + + var body: some ToolbarContent { + ToolbarItem(placement: placement) { + HStack { + content + } + .foregroundColor(tint).disabled(disabled) + } + } +} + +struct ToolbarFeaturesMenu: View { + private let content: Content + private let symbolRenderingMode: SymbolRenderingMode + + init(symbolRenderingMode: SymbolRenderingMode = .monochrome, @ViewBuilder content: () -> Content) { + self.content = content() + self.symbolRenderingMode = symbolRenderingMode + } + + var body: some View { + Menu { + content + } label: { + Image(systemSymbol: .ellipsisCircle) + .symbolRenderingMode(symbolRenderingMode) + } + } +} + +struct FiltersButton: View { + private let hideText: Bool + private let action: () -> Void + + init(hideText: Bool = false, action: @escaping () -> Void) { + self.hideText = hideText + self.action = action + } + + var body: some View { + Button(action: action) { + Image(systemSymbol: .line3HorizontalDecrease) + if !hideText { + Text(R.string.localizable.toolbarItemButtonFilters()) + } + } + } +} + +struct QuickSearchButton: View { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + var body: some View { + Button(action: action) { + Image(systemSymbol: .magnifyingglass) + Text(R.string.localizable.toolbarItemButtonQuickSearch()) + } + } +} + +struct JumpPageButton: View { + private let pageNumber: PageNumber + private let hideText: Bool + private let action: () -> Void + + init(pageNumber: PageNumber, hideText: Bool = false, action: @escaping () -> Void) { + self.pageNumber = pageNumber + self.hideText = hideText + self.action = action + } + + var body: some View { + Button(action: action) { + Image(systemSymbol: .arrowshapeBounceForward) + if !hideText { + Text(R.string.localizable.toolbarItemButtonJumpPage()) + } + } + .disabled(pageNumber.isSinglePage) + } +} + +struct FavoritesIndexMenu: View { + private let user: User + private let index: Int + private let action: (Int) -> Void + + init(user: User, index: Int, action: @escaping (Int) -> Void) { + self.user = user + self.index = index + self.action = action + } + + var body: some View { + Menu { + ForEach(-1..<10) { index in + Button { + action(index) + } label: { + Text(user.getFavoriteCategory(index: index)) + if index == self.index { + Image(systemSymbol: .checkmark) + } + } + } + } label: { + Image(systemSymbol: .dialMin) + .symbolRenderingMode(.hierarchical) + } + } +} + +struct ToplistsTypeMenu: View { + private let type: ToplistsType + private let action: (ToplistsType) -> Void + + init(type: ToplistsType, action: @escaping (ToplistsType) -> Void) { + self.type = type + self.action = action + } + + var body: some View { + Menu { + ForEach(ToplistsType.allCases) { type in + Button { + action(type) + } label: { + Text(type.value) + if type == self.type { + Image(systemSymbol: .checkmark) + } + } + } + } label: { + Image(systemSymbol: .dialMin) + .symbolRenderingMode(.hierarchical) + } + } +} + +struct SortOrderMenu: View { + private let sortOrder: FavoritesSortOrder? + private let action: (FavoritesSortOrder) -> Void + + init(sortOrder: FavoritesSortOrder?, action: @escaping (FavoritesSortOrder) -> Void) { + self.sortOrder = sortOrder + self.action = action + } + + var body: some View { + Menu { + ForEach(FavoritesSortOrder.allCases) { order in + Button { + action(order) + } label: { + Text(order.value) + if order == sortOrder { + Image(systemSymbol: .checkmark) + } + } + } + } label: { + Image(systemSymbol: .arrowUpArrowDownCircle) + .symbolRenderingMode(.hierarchical) + } + } +} diff --git a/EhPanda/View/Tools/WaveForm.swift b/EhPanda/View/Support/Components/WaveForm.swift similarity index 100% rename from EhPanda/View/Tools/WaveForm.swift rename to EhPanda/View/Support/Components/WaveForm.swift diff --git a/EhPanda/View/Support/FiltersStore.swift b/EhPanda/View/Support/FiltersStore.swift new file mode 100644 index 00000000..d47ee16f --- /dev/null +++ b/EhPanda/View/Support/FiltersStore.swift @@ -0,0 +1,110 @@ +// +// FiltersStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct FiltersState: Equatable { + enum Route { + case resetFilters + } + enum FocusedBound { + case lower + case upper + } + + @BindableState var route: Route? + @BindableState var filterRange: FilterRange = .search + @BindableState var focusedBound: FocusedBound? + + @BindableState var searchFilter = Filter() + @BindableState var globalFilter = Filter() + @BindableState var watchedFilter = Filter() +} + +enum FiltersAction: BindableAction { + case binding(BindingAction) + case setNavigation(FiltersState.Route?) + case onTextFieldSubmitted + + case syncFilter(FilterRange) + case resetFilters + case fetchFilters + case fetchFiltersDone(AppEnv) +} + +struct FiltersEnvironment { + let databaseClient: DatabaseClient +} + +let filtersReducer = Reducer { state, action, environment in + switch action { + case .binding(\.$searchFilter): + state.searchFilter.fixInvalidData() + return .init(value: .syncFilter(.search)) + + case .binding(\.$globalFilter): + state.globalFilter.fixInvalidData() + return .init(value: .syncFilter(.global)) + + case .binding(\.$watchedFilter): + state.watchedFilter.fixInvalidData() + return .init(value: .syncFilter(.watched)) + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .onTextFieldSubmitted: + switch state.focusedBound { + case .lower: + state.focusedBound = .upper + case .upper: + state.focusedBound = nil + default: + break + } + return .none + + case .syncFilter(let range): + let filter: Filter + switch range { + case .search: + filter = state.searchFilter + case .global: + filter = state.globalFilter + case .watched: + filter = state.watchedFilter + } + return environment.databaseClient.updateFilter(filter, range: range).fireAndForget() + + case .resetFilters: + switch state.filterRange { + case .search: + state.searchFilter = .init() + return .init(value: .syncFilter(.search)) + case .global: + state.globalFilter = .init() + return .init(value: .syncFilter(.global)) + case .watched: + state.watchedFilter = .init() + return .init(value: .syncFilter(.watched)) + } + + case .fetchFilters: + return environment.databaseClient.fetchAppEnv().map(FiltersAction.fetchFiltersDone) + + case .fetchFiltersDone(let appEnv): + state.searchFilter = appEnv.searchFilter + state.globalFilter = appEnv.globalFilter + state.watchedFilter = appEnv.watchedFilter + return .none + } +} +.binding() diff --git a/EhPanda/View/Support/FiltersView.swift b/EhPanda/View/Support/FiltersView.swift new file mode 100644 index 00000000..b801ca80 --- /dev/null +++ b/EhPanda/View/Support/FiltersView.swift @@ -0,0 +1,256 @@ +// +// FiltersView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/01/08. +// + +import SwiftUI +import ComposableArchitecture + +struct FiltersView: View { + private let store: Store + @ObservedObject private var viewStore: ViewStore + + @FocusState private var focusedBound: FiltersState.FocusedBound? + + init(store: Store) { + self.store = store + viewStore = ViewStore(store) + } + + private var filter: Binding { + switch viewStore.filterRange { + case .search: + return viewStore.binding(\.$searchFilter) + case .global: + return viewStore.binding(\.$globalFilter) + case .watched: + return viewStore.binding(\.$watchedFilter) + } + } + + // MARK: FilterView + var body: some View { + NavigationView { + Form { + BasicSection( + route: viewStore.binding(\.$route), + filter: filter, filterRange: viewStore.binding(\.$filterRange), + resetFiltersAction: { viewStore.send(.resetFilters) }, + resetFiltersDialogAction: { viewStore.send(.setNavigation(.resetFilters)) } + ) + AdvancedSection( + filter: filter, focusedBound: $focusedBound, + submitAction: { viewStore.send(.onTextFieldSubmitted) } + ) + } + .synchronize(viewStore.binding(\.$focusedBound), $focusedBound) + .navigationTitle(R.string.localizable.filtersViewTitleFilters()) + .onAppear { viewStore.send(.fetchFilters) } + } + } +} + +// MARK: BasicSection +private struct BasicSection: View { + @Binding private var route: FiltersState.Route? + @Binding private var filter: Filter + @Binding private var filterRange: FilterRange + private let resetFiltersAction: () -> Void + private let resetFiltersDialogAction: () -> Void + private var categoryBindings: [Binding] { [ + $filter.doujinshi, $filter.manga, $filter.artistCG, $filter.gameCG, $filter.western, + $filter.nonH, $filter.imageSet, $filter.cosplay, $filter.asianPorn, $filter.misc + ] } + + init( + route: Binding, filter: Binding, filterRange: Binding, + resetFiltersAction: @escaping () -> Void, resetFiltersDialogAction: @escaping () -> Void + ) { + _route = route + _filter = filter + _filterRange = filterRange + self.resetFiltersAction = resetFiltersAction + self.resetFiltersDialogAction = resetFiltersDialogAction + } + + var body: some View { + Section { + Picker("", selection: $filterRange) { + ForEach(FilterRange.allCases) { range in + Text(range.value).tag(range) + } + } + .pickerStyle(.segmented) + CategoryView(bindings: categoryBindings) + Button(action: resetFiltersDialogAction) { + Text(R.string.localizable.filtersViewButtonResetFilters()).foregroundStyle(.red) + } + .confirmationDialog( + message: R.string.localizable.confirmationDialogTitleReset(), + unwrapping: $route, case: /FiltersState.Route.resetFilters + ) { + Button( + R.string.localizable.confirmationDialogButtonReset(), + role: .destructive, action: resetFiltersAction + ) + } + Toggle(R.string.localizable.filtersViewTitleAdvancedSettings(), isOn: $filter.advanced) + } + } +} + +// MARK: AdvancedSection +private struct AdvancedSection: View { + @Binding private var filter: Filter + private let focusedBound: FocusState.Binding + private let submitAction: () -> Void + + init( + filter: Binding, + focusedBound: FocusState.Binding, + submitAction: @escaping () -> Void + ) { + _filter = filter + self.focusedBound = focusedBound + self.submitAction = submitAction + } + + var body: some View { + Group { + Section(R.string.localizable.filtersViewSectionTitleAdvanced()) { + Toggle(R.string.localizable.filtersViewTitleSearchGalleryName(), isOn: $filter.galleryName) + Toggle(R.string.localizable.filtersViewTitleSearchGalleryTags(), isOn: $filter.galleryTags) + Toggle(R.string.localizable.filtersViewTitleSearchGalleryDescription(), isOn: $filter.galleryDesc) + Toggle(R.string.localizable.filtersViewTitleSearchTorrentFilenames(), isOn: $filter.torrentFilenames) + Toggle( + R.string.localizable.filtersViewTitleOnlyShowGalleriesWithTorrents(), + isOn: $filter.onlyWithTorrents + ) + Toggle(R.string.localizable.filtersViewTitleSearchLowPowerTags(), isOn: $filter.lowPowerTags) + Toggle(R.string.localizable.filtersViewTitleSearchDownvotedTags(), isOn: $filter.downvotedTags) + Toggle(R.string.localizable.filtersViewTitleShowExpungedGalleries(), isOn: $filter.expungedGalleries) + } + Section { + Toggle(R.string.localizable.filtersViewTitleSetMinimumRating(), isOn: $filter.minRatingActivated) + MinimumRatingSetter(minimum: $filter.minRating) + .disabled(!filter.minRatingActivated) + Toggle(R.string.localizable.filtersViewTitleSetPagesRange(), isOn: $filter.pageRangeActivated) + .disabled(focusedBound.wrappedValue != nil) + PagesRangeSetter( + lowerBound: $filter.pageLowerBound, + upperBound: $filter.pageUpperBound, + focusedBound: focusedBound, + submitAction: submitAction + ) + .disabled(!filter.pageRangeActivated) + } + Section(R.string.localizable.filtersViewSectionTitleDefaultFilter()) { + Toggle(R.string.localizable.filtersViewTitleDisableLanguageFilter(), isOn: $filter.disableLanguage) + Toggle(R.string.localizable.filtersViewTitleDisableUploaderFilter(), isOn: $filter.disableUploader) + Toggle(R.string.localizable.filtersViewTitleDisableTagsFilter(), isOn: $filter.disableTags) + } + } + .disabled(!filter.advanced) + } +} + +// MARK: MinimumRatingSetter +private struct MinimumRatingSetter: View { + @Binding private var minimum: Int + + init(minimum: Binding) { + _minimum = minimum + } + + var body: some View { + HStack { + Text(R.string.localizable.filtersViewTitleMinimumRating()) + Spacer() + Picker(selection: $minimum, label: Text(R.string.localizable.commonValueStars("\(minimum)"))) { + ForEach(Array(2...5), id: \.self) { num in + Text(R.string.localizable.commonValueStars("\(minimum)")).tag(num) + } + } + .pickerStyle(.menu) + } + } +} + +// MARK: PagesRangeSetter +private struct PagesRangeSetter: View { + @Binding private var lowerBound: String + @Binding private var upperBound: String + private let focusedBound: FocusState.Binding + private let submitAction: () -> Void + + init( + lowerBound: Binding, + upperBound: Binding, + focusedBound: FocusState.Binding, + submitAction: @escaping () -> Void + ) { + _lowerBound = lowerBound + _upperBound = upperBound + self.focusedBound = focusedBound + self.submitAction = submitAction + } + + var body: some View { + HStack { + Text(R.string.localizable.filtersViewTitlePagesRange()) + Spacer() + SettingTextField(text: $lowerBound) + .focused(focusedBound, equals: .lower) + .submitLabel(.next) + Text("-") + SettingTextField(text: $upperBound) + .focused(focusedBound, equals: .upper) + .submitLabel(.done) + } + .onSubmit(submitAction) + } +} + +// MARK: Definition +private struct TupleCategory: Identifiable { + var id: String { category.rawValue } + + let isFiltered: Binding + let category: Category +} + +enum FilterRange: Int, CaseIterable, Identifiable { + var id: Int { rawValue } + + case search + case global + case watched +} +extension FilterRange { + var value: String { + switch self { + case .search: + return R.string.localizable.enumFilterRangeValueSearch() + case .global: + return R.string.localizable.enumFilterRangeValueGlobal() + case .watched: + return R.string.localizable.enumFilterRangeValueWatched() + } + } +} + +struct FiltersView_Previews: PreviewProvider { + static var previews: some View { + FiltersView( + store: .init( + initialState: .init(), + reducer: filtersReducer, + environment: FiltersEnvironment( + databaseClient: .live + ) + ) + ) + } +} diff --git a/EhPanda/View/Setting/NewDawnView.swift b/EhPanda/View/Support/NewDawnView.swift similarity index 53% rename from EhPanda/View/Setting/NewDawnView.swift rename to EhPanda/View/Support/NewDawnView.swift index 8e1b7a04..97a7242f 100644 --- a/EhPanda/View/Setting/NewDawnView.swift +++ b/EhPanda/View/Support/NewDawnView.swift @@ -7,14 +7,16 @@ import SwiftUI -private let sunWidth = DeviceUtil.windowW * (DeviceUtil.isPad ? 0.5 : 0.6) - struct NewDawnView: View { @Environment(\.colorScheme) private var colorScheme - @State private var rotationAngle: Double = 0 - @State private var greeting: Greeting? + private let greeting: Greeting - private let offset = DeviceUtil.windowW * 0.2 + private var offset: Double { + DeviceUtil.windowW * 0.2 + } + private var sunWidth: Double { + DeviceUtil.windowW * (DeviceUtil.isPad ? 0.5 : 0.6) + } private var gradientColors: [Color] { if colorScheme == .light { @@ -24,59 +26,40 @@ struct NewDawnView: View { } } - init(greeting: Greeting?) { - _greeting = State(initialValue: greeting) + init(greeting: Greeting) { + self.greeting = greeting } // MARK: NewDawnView var body: some View { - TimelineView(.animation) { timeline in - let now = timeline.date.timeIntervalSince1970 - let angle = Angle.degrees(now * 50) - - ZStack { - LinearGradient( - gradient: Gradient(colors: gradientColors), - startPoint: .top, endPoint: .bottom - ) - VStack { - HStack { - Spacer() - ZStack { - SunView() - SunBeamView() - .rotationEffect( - colorScheme == .light - ? angle : Angle(degrees: 0) - ) - .opacity(colorScheme == .light ? 1 : 0) - } - .offset(x: offset, y: -offset) - } + ZStack { + LinearGradient( + gradient: Gradient(colors: gradientColors), + startPoint: .top, endPoint: .bottom + ) + VStack { + HStack { Spacer() - } - VStack(spacing: 50) { - VStack(spacing: 10) { - TextView( - text: "It is the dawn of a new day!", - font: .largeTitle - ) - TextView( - text: "Reflecting on your journey so far, " - + "you find that you are a little wiser.", - font: .title2 - ) + ZStack { + SunView(width: sunWidth) + SunBeamView(width: sunWidth) + .opacity(colorScheme == .light ? 1 : 0) } - TextView( - text: greeting?.gainContent ?? "", - font: .title3, fontWeight: .bold - ) + .offset(x: offset, y: -offset) + } + Spacer() + } + VStack(spacing: 50) { + VStack(spacing: 10) { + TextView(text: R.string.localizable.newDawnViewTitleFirst(), font: .largeTitle) + TextView(text: R.string.localizable.newDawnViewTitleSecond(), font: .title2) } - .padding() + TextView(text: greeting.gainContent ?? "", font: .title3, fontWeight: .bold) } - .drawingGroup() - .ignoresSafeArea() + .padding() } + .drawingGroup() + .ignoresSafeArea() } } @@ -99,7 +82,7 @@ private struct TextView: View { var body: some View { HStack { - Text(text.localized) + Text(text) .fontWeight(fontWeight).font(font) .lineLimit(nil).foregroundStyle(.white) .fixedSize(horizontal: false, vertical: true) @@ -110,7 +93,11 @@ private struct TextView: View { // MARK: SunView private struct SunView: View { - private let width = sunWidth + private let width: Double + + init(width: Double) { + self.width = width + } var body: some View { ZStack { @@ -122,7 +109,12 @@ private struct SunView: View { // MARK: SunBeamView private struct SunBeamView: View { - private let width = sunWidth + private let width: Double + + init(width: Double) { + self.width = width + } + private var offset: CGFloat { width / 1.2 } private var evenOffset: CGFloat { offset / sqrt(2) } private var sizes: [CGSize] { @@ -143,7 +135,7 @@ private struct SunBeamView: View { var body: some View { ForEach(0..<8, id: \.self) { index in - SunBeamItem() + SunBeamItem(width: width / 10) .rotationEffect(Angle(degrees: degrees[index])) .offset(sizes[index]) } @@ -152,7 +144,11 @@ private struct SunBeamView: View { // MARK: SunBeamItem private struct SunBeamItem: View { - private let width = sunWidth / 10 + private let width: Double + + init(width: Double) { + self.width = width + } var body: some View { Rectangle() @@ -164,15 +160,9 @@ private struct SunBeamItem: View { struct NewDawnView_Previews: PreviewProvider { static var previews: some View { - var greeting = Greeting() - greeting.gainedEXP = 10 - greeting.gainedCredits = 10000 - greeting.gainedGP = 10000 - greeting.gainedHath = 10 - - return Text("") - .sheet(isPresented: .constant(true), content: { - NewDawnView(greeting: greeting) - }) + Text("") + .sheet(isPresented: .constant(true)) { + NewDawnView(greeting: .mock) + } } } diff --git a/EhPanda/View/TabBar/TabBarStore.swift b/EhPanda/View/TabBar/TabBarStore.swift new file mode 100644 index 00000000..3da6db7c --- /dev/null +++ b/EhPanda/View/TabBar/TabBarStore.swift @@ -0,0 +1,30 @@ +// +// TabBarStore.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/29. +// + +import ComposableArchitecture + +struct TabBarState: Equatable { + var tabBarItemType: TabBarItemType = .home +} + +enum TabBarAction { + case setTabBarItemType(TabBarItemType) +} + +struct TabBarEnvironment { + let deviceClient: DeviceClient +} + +let tabBarReducer = Reducer { state, action, environment in + switch action { + case .setTabBarItemType(let type): + if !environment.deviceClient.isPad() || type != .setting { + state.tabBarItemType = type + } + return .none + } +} diff --git a/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift new file mode 100644 index 00000000..6c4bffbc --- /dev/null +++ b/EhPanda/View/TabBar/TabBarView.swift @@ -0,0 +1,174 @@ +// +// TabBarView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/29. +// + +import SwiftUI +import SFSafeSymbols +import ComposableArchitecture + +struct TabBarView: View { + @Environment(\.scenePhase) private var scenePhase + private let store: Store + @ObservedObject private var viewStore: ViewStore + + init(store: Store) { + self.store = store + viewStore = ViewStore(store) + } + + var body: some View { + ZStack { + TabView( + selection: .init( + get: { viewStore.tabBarState.tabBarItemType }, + set: { viewStore.send(.tabBar(.setTabBarItemType($0))) } + ) + ) { + ForEach(TabBarItemType.allCases) { type in + Group { + switch type { + case .home: + HomeView( + store: store.scope(state: \.homeState, action: AppAction.home), + user: viewStore.settingState.user, + setting: viewStore.binding(\.settingState.$setting), + blurRadius: viewStore.appLockState.blurRadius, + tagTranslator: viewStore.settingState.tagTranslator + ) + case .favorites: + FavoritesView( + store: store.scope(state: \.favoritesState, action: AppAction.favorites), + user: viewStore.settingState.user, + setting: viewStore.binding(\.settingState.$setting), + blurRadius: viewStore.appLockState.blurRadius, + tagTranslator: viewStore.settingState.tagTranslator + ) + case .search: + SearchRootView( + store: store.scope(state: \.searchRootState, action: AppAction.searchRoot), + user: viewStore.settingState.user, + setting: viewStore.binding(\.settingState.$setting), + blurRadius: viewStore.appLockState.blurRadius, + tagTranslator: viewStore.settingState.tagTranslator + ) + case .setting: + SettingView( + store: store.scope(state: \.settingState, action: AppAction.setting), + blurRadius: viewStore.appLockState.blurRadius + ) + } + } + .tabItem(type.label).tag(type) + } + .accentColor(viewStore.settingState.setting.accentColor) + } + .autoBlur(radius: viewStore.appLockState.blurRadius) + Image(systemSymbol: .lockFill).font(.system(size: 80)) + .opacity(viewStore.appLockState.isAppLocked ? 1 : 0) + } + .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteState.Route.newDawn) { route in + NewDawnView(greeting: route.wrappedValue) + .autoBlur(radius: viewStore.appLockState.blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteState.Route.setting) { _ in + SettingView( + store: store.scope(state: \.settingState, action: AppAction.setting), + blurRadius: viewStore.appLockState.blurRadius + ) + .accentColor(viewStore.settingState.setting.accentColor) + .autoBlur(radius: viewStore.appLockState.blurRadius) + } + .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteState.Route.detail) { route in + NavigationView { + DetailView( + store: store.scope(state: \.appRouteState.detailState, action: { AppAction.appRoute(.detail($0)) }), + gid: route.wrappedValue, user: viewStore.settingState.user, + setting: viewStore.binding(\.settingState.$setting), + blurRadius: viewStore.appLockState.blurRadius, + tagTranslator: viewStore.settingState.tagTranslator + ) + } + .accentColor(viewStore.settingState.setting.accentColor) + .autoBlur(radius: viewStore.appLockState.blurRadius) + .environment(\.inSheet, true) + } + .progressHUD( + config: viewStore.appRouteState.hudConfig, + unwrapping: viewStore.binding(\.appRouteState.$route), + case: /AppRouteState.Route.hud + ) + .onChange(of: scenePhase) { viewStore.send(.onScenePhaseChange($0)) } + .onOpenURL { viewStore.send(.appRoute(.handleDeepLink($0))) } + } +} + +// MARK: TabType +enum TabBarItemType: Int, CaseIterable, Identifiable { + var id: Int { rawValue } + + case home + case favorites + case search + case setting +} + +extension TabBarItemType { + var title: String { + switch self { + case .home: + return R.string.localizable.tabItemTitleHome() + case .favorites: + return R.string.localizable.tabItemTitleFavorites() + case .search: + return R.string.localizable.tabItemTitleSearch() + case .setting: + return R.string.localizable.tabItemTitleSetting() + } + } + var symbol: SFSymbol { + switch self { + case .home: + return .houseCircle + case .favorites: + return .heartCircle + case .search: + return .magnifyingglassCircle + case .setting: + return .gearshapeCircle + } + } + func label() -> Label { + Label(title, systemSymbol: symbol) + } +} + +struct TabBarView_Previews: PreviewProvider { + static var previews: some View { + TabBarView( + store: .init( + initialState: .init(), + reducer: appReducer, + environment: AppEnvironment( + dfClient: .live, + urlClient: .live, + fileClient: .live, + imageClient: .live, + deviceClient: .live, + loggerClient: .live, + hapticClient: .live, + libraryClient: .live, + cookiesClient: .live, + databaseClient: .live, + clipboardClient: .live, + appDelegateClient: .live, + userDefaultsClient: .live, + uiApplicationClient: .live, + authorizationClient: .live + ) + ) + ) + } +} diff --git a/EhPanda/View/Tools/AlertView.swift b/EhPanda/View/Tools/AlertView.swift deleted file mode 100644 index 0f9ec37e..00000000 --- a/EhPanda/View/Tools/AlertView.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// AlertView.swift -// EhPanda -// -// Created by 荒木辰造 on R 2/12/27. -// - -import SwiftUI - -struct LoadingView: View { - var body: some View { - ProgressView("Loading...") - } -} - -struct LoadMoreFooter: View { - private var moreLoadingFlag: Bool - private var moreLoadFailedFlag: Bool - private var retryAction: (() -> Void)? - private var symbolName = "exclamationmark.arrow.triangle.2.circlepath" - - init(moreLoadingFlag: Bool, moreLoadFailedFlag: Bool, retryAction: (() -> Void)?) { - self.moreLoadingFlag = moreLoadingFlag - self.moreLoadFailedFlag = moreLoadFailedFlag - self.retryAction = retryAction - } - - var body: some View { - HStack(alignment: .center) { - Spacer() - ZStack { - ProgressView().opacity(moreLoadingFlag ? 1 : 0) - Button { - retryAction?() - } label: { - Image(systemName: symbolName).foregroundStyle(.red).imageScale(.large) - } - .opacity(moreLoadFailedFlag ? 1 : 0) - } - Spacer() - } - .frame(height: 50) - } -} - -struct ErrorView: View { - private let error: AppError - private let retryAction: (() -> Void)? - - init(error: AppError, retryAction: (() -> Void)? = nil) { - self.error = error - self.retryAction = retryAction - } - - var body: some View { - GenericRetryView( - symbolName: error.symbolName, message: error.alertText, - buttonText: "Retry", retryAction: retryAction - ) - } -} - -struct GenericRetryView: View { - @Environment(\.colorScheme) private var colorScheme - private let symbolName: String - private let message: String - private let buttonText: String - private let retryAction: (() -> Void)? - - init(symbolName: String, message: String, buttonText: String, retryAction: (() -> Void)?) { - self.symbolName = symbolName - self.message = message - self.buttonText = buttonText - self.retryAction = retryAction - } - - var body: some View { - VStack { - Image(systemName: symbolName).font(.system(size: 50)).padding(.bottom, 15) - Text(message.localized).multilineTextAlignment(.center).foregroundStyle(.gray) - .font(.headline).padding(.bottom, 5) - if let action = retryAction { - Button(action: action) { - Text(buttonText.localized).foregroundColor(.primary.opacity(0.7)).textCase(.uppercase) - } - .buttonStyle(.bordered).buttonBorderShape(.capsule) - } - } - .frame(maxWidth: DeviceUtil.windowW * 0.8) - } -} - -struct PageJumpView: View { - @Environment(\.colorScheme) private var colorScheme - @Binding private var inputText: String - private var isFocused: FocusState.Binding - private let pageNumber: PageNumber - - init(inputText: Binding, isFocused: FocusState.Binding, pageNumber: PageNumber) { - _inputText = inputText - self.isFocused = isFocused - self.pageNumber = pageNumber - } - - var body: some View { - VStack { - Text("Jump page").bold() - HStack { - let opacity = colorScheme == .light ? 0.15 : 0.1 - TextField(inputText, text: $inputText).multilineTextAlignment(.center).keyboardType(.numberPad) - .padding(.horizontal, 10).padding(.vertical, 5).background(Color.gray.opacity(opacity)) - .cornerRadius(5).frame(width: 75).focused(isFocused.projectedValue) - Text("-") - Text("\(pageNumber.maximum + 1)") - } - .lineLimit(1) - } - } -} diff --git a/EhPanda/View/Tools/Comment.swift b/EhPanda/View/Tools/Comment.swift deleted file mode 100644 index 6c61cf40..00000000 --- a/EhPanda/View/Tools/Comment.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// Comment.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/03. -// - -import SwiftUI - -struct CommentButton: View { - private let action: () -> Void - - init(action: @escaping () -> Void) { - self.action = action - } - - var body: some View { - Button(action: action) { - HStack { - Spacer() - Image(systemName: "square.and.pencil") - Text("Post Comment").fontWeight(.bold) - Spacer() - } - .padding().background(Color(.systemGray6)).cornerRadius(15) - } - } -} - -struct DraftCommentView: View { - @Binding private var content: String - @FocusState private var isTextEditorFocused: Bool - - private let title: String - private let postAction: () -> Void - private let cancelAction: () -> Void - - init( - content: Binding, title: String, - postAction: @escaping () -> Void, - cancelAction: @escaping () -> Void - ) { - _content = content - self.title = title - self.postAction = postAction - self.cancelAction = cancelAction - } - - var body: some View { - NavigationView { - VStack { - TextEditor(text: $content).focused($isTextEditorFocused).padding() - Spacer() - } - .navigationBarTitle(title.localized, displayMode: .inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel", action: cancelAction) - } - ToolbarItem(placement: .confirmationAction) { - Button("Post", action: postAction).disabled(content.isEmpty) - } - } - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - isTextEditorFocused = true - } - } - } -} diff --git a/EhPanda/View/Tools/GenericList.swift b/EhPanda/View/Tools/GenericList.swift deleted file mode 100644 index a7785e81..00000000 --- a/EhPanda/View/Tools/GenericList.swift +++ /dev/null @@ -1,192 +0,0 @@ -// -// GenericList.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/07/25. -// - -import SwiftUI -import WaterfallGrid - -struct GenericList: View { - private let items: [Gallery] - private let setting: Setting - private let pageNumber: PageNumber? - private let loadingFlag: Bool - private let loadError: AppError? - private let moreLoadingFlag: Bool - private let moreLoadFailedFlag: Bool - private let fetchAction: (() -> Void)? - private let loadMoreAction: (() -> Void)? - private let translateAction: ((String) -> String)? - - init( - items: [Gallery], setting: Setting, pageNumber: PageNumber?, - loadingFlag: Bool, loadError: AppError?, moreLoadingFlag: Bool, - moreLoadFailedFlag: Bool, fetchAction: (() -> Void)? = nil, - loadMoreAction: (() -> Void)? = nil, - translateAction: ((String) -> String)? = nil - ) { - self.items = items - self.setting = setting - self.pageNumber = pageNumber - self.loadingFlag = loadingFlag - self.loadError = loadError - self.moreLoadingFlag = moreLoadingFlag - self.moreLoadFailedFlag = moreLoadFailedFlag - self.fetchAction = fetchAction - self.loadMoreAction = loadMoreAction - self.translateAction = translateAction - } - - var body: some View { - if loadingFlag { - LoadingView() - } else if let error = loadError { - ErrorView(error: error, retryAction: fetchAction) - } else { - VStack(spacing: 0) { - switch setting.listMode { - case .detail: - DetailList( - items: items, setting: setting, pageNumber: pageNumber, - moreLoadingFlag: moreLoadingFlag, moreLoadFailedFlag: moreLoadFailedFlag, - loadMoreAction: loadMoreAction, translateAction: translateAction - ) - case .thumbnail: - WaterfallList( - items: items, setting: setting, pageNumber: pageNumber, - moreLoadingFlag: moreLoadingFlag, moreLoadFailedFlag: moreLoadFailedFlag, - loadMoreAction: loadMoreAction, translateAction: translateAction - ) - } - } - .transition(AppUtil.opacityTransition) - .refreshable { fetchAction?() } - } - } -} - -// MARK: DetailList -private struct DetailList: View { - private let items: [Gallery] - private let setting: Setting - private let pageNumber: PageNumber? - private let moreLoadingFlag: Bool - private let moreLoadFailedFlag: Bool - private let loadMoreAction: (() -> Void)? - private let translateAction: ((String) -> String)? - - init( - items: [Gallery], setting: Setting, pageNumber: PageNumber?, - moreLoadingFlag: Bool, moreLoadFailedFlag: Bool, - loadMoreAction: (() -> Void)?, - translateAction: ((String) -> String)? = nil - ) { - self.items = items - self.setting = setting - self.pageNumber = pageNumber - self.moreLoadingFlag = moreLoadingFlag - self.moreLoadFailedFlag = moreLoadFailedFlag - self.loadMoreAction = loadMoreAction - self.translateAction = translateAction - } - - private var inValidRange: Bool { - guard let pageNumber = pageNumber else { return false } - return pageNumber.current + 1 <= pageNumber.maximum - } - - var body: some View { - List(items) { item in - GalleryDetailCell(gallery: item, setting: setting, translateAction: translateAction) - .background { NavigationLink(destination: DetailView(gid: item.gid)) {}.opacity(0) } - .onAppear { - guard item == items.last else { return } - loadMoreAction?() - } - if (moreLoadingFlag || moreLoadFailedFlag) && item == items.last && inValidRange { - LoadMoreFooter( - moreLoadingFlag: moreLoadingFlag, moreLoadFailedFlag: moreLoadFailedFlag, - retryAction: loadMoreAction - ) - } - } - } -} - -// MARK: WaterfallList -private struct WaterfallList: View { - @State var gid: String = "" - @State var isNavLinkActive = false - - private let items: [Gallery] - private let setting: Setting - private let pageNumber: PageNumber? - private let moreLoadingFlag: Bool - private let moreLoadFailedFlag: Bool - private let loadMoreAction: (() -> Void)? - private let translateAction: ((String) -> String)? - - private var columnsInPortrait: Int { - DeviceUtil.isPadWidth ? 4 : 2 - } - private var columnsInLandscape: Int { - DeviceUtil.isPadWidth ? 5 : 2 - } - private var inValidRange: Bool { - guard let pageNumber = pageNumber else { return false } - return pageNumber.current + 1 <= pageNumber.maximum - } - - init( - items: [Gallery], setting: Setting, pageNumber: PageNumber?, - moreLoadingFlag: Bool, moreLoadFailedFlag: Bool, - loadMoreAction: (() -> Void)?, - translateAction: ((String) -> String)? = nil - ) { - self.items = items - self.setting = setting - self.pageNumber = pageNumber - self.moreLoadingFlag = moreLoadingFlag - self.moreLoadFailedFlag = moreLoadFailedFlag - self.loadMoreAction = loadMoreAction - self.translateAction = translateAction - } - - var body: some View { - List { - WaterfallGrid(items) { item in - GalleryThumbnailCell(gallery: item, setting: setting, translateAction: translateAction) - .onTapGesture { - gid = item.gid - isNavLinkActive.toggle() - } - } - .gridStyle( - columnsInPortrait: columnsInPortrait, columnsInLandscape: columnsInLandscape, - spacing: 15, animation: nil - ) - if !moreLoadingFlag && !moreLoadFailedFlag && inValidRange { - Button { - loadMoreAction?() - } label: { - HStack { - Spacer() - Image(systemName: "chevron.down") - Spacer() - } - } - .foregroundStyle(.tint) - } - if moreLoadingFlag || moreLoadFailedFlag { - LoadMoreFooter( - moreLoadingFlag: moreLoadingFlag, moreLoadFailedFlag: moreLoadFailedFlag, - retryAction: loadMoreAction - ) - } - } - .background { NavigationLink(destination: DetailView(gid: gid), isActive: $isNavLinkActive) {}.opacity(0) } - .listStyle(.plain) - } -} diff --git a/EhPanda/View/Tools/SuggestionProvider.swift b/EhPanda/View/Tools/SuggestionProvider.swift deleted file mode 100644 index f31e6e2a..00000000 --- a/EhPanda/View/Tools/SuggestionProvider.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// SuggestionProvider.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/11/23. -// - -import SwiftUI - -struct SuggestionProvider: View { - @EnvironmentObject var store: Store - @Binding private var keyword: String - - init(keyword: Binding) { - _keyword = keyword - } - - private var keywords: [String] { - store.appState.homeInfo.historyKeywords.reversed().filter({ word in - keyword.isEmpty ? true : word.contains(keyword) - }) - } - - var body: some View { - ForEach(keywords, id: \.self) { word in - HStack { - Text(word).foregroundStyle(.tint) - Spacer() - Image(systemName: "xmark").imageScale(.small) - .foregroundColor(.secondary).onTapGesture { - store.dispatch(.removeHistoryKeyword(text: word)) - } - } - .contentShape(Rectangle()) - .onTapGesture { keyword = word } - } - } -} diff --git a/EhPandaTests/ParserTests.swift b/EhPandaTests/ParserTests.swift index e628934d..4e2b6d27 100644 --- a/EhPandaTests/ParserTests.swift +++ b/EhPandaTests/ParserTests.swift @@ -61,7 +61,7 @@ class GalleryParserTests: XCTestCase, TestHelper { XCTAssertEqual(detail.gid, "1990291") XCTAssertEqual(detail.title, "[Hiroya] Shirotaegiku | Dusty miller (COMIC ExE 32) [English] [INSURRECTION] [Digital]") XCTAssertEqual(detail.jpnTitle, "[広弥] 白妙菊 (コミック エグゼ 32) [英訳] [DL版]") - XCTAssertFalse(detail.isFavored) + XCTAssertFalse(detail.isFavorited) XCTAssertEqual(detail.visibility, .yes) XCTAssertEqual(detail.rating, 5) XCTAssertEqual(detail.userRating, 0) @@ -69,16 +69,16 @@ class GalleryParserTests: XCTestCase, TestHelper { XCTAssertEqual(detail.category, .manga) XCTAssertEqual(detail.language, .english) XCTAssertEqual(detail.uploader, "Gekkou98") - XCTAssertEqual(detail.coverURL, "https://ehgt.org/t/d3/6b/d36b5a7a97f074fc7cbda7182884b71ae1143d57-883501-1416-2000-png_250.jpg") - XCTAssertEqual(detail.archiveURL, "https://e-hentai.org/archiver.php?gid=1990291&token=neVEr&or=goNNA--leTYoUdown") + XCTAssertEqual(detail.coverURL?.absoluteString, "https://ehgt.org/t/d3/6b/d36b5a7a97f074fc7cbda7182884b71ae1143d57-883501-1416-2000-png_250.jpg") + XCTAssertEqual(detail.archiveURL?.absoluteString, "https://e-hentai.org/archiver.php?gid=1990291&token=neVEr&or=goNNA--leTYoUdown") XCTAssertNil(detail.parentURL) - XCTAssertEqual(detail.favoredCount, 237) + XCTAssertEqual(detail.favoritedCount, 237) XCTAssertEqual(detail.pageCount, 35) XCTAssertEqual(detail.sizeCount, 44.8) XCTAssertEqual(detail.sizeType, "MB") XCTAssertEqual(detail.torrentCount, 1) XCTAssertEqual(state.tags.count, 4) - XCTAssertEqual(state.previews.count, 20) + XCTAssertEqual(state.previewURLs.count, 20) XCTAssertEqual(state.previewConfig, .large(rows: 4)) XCTAssertEqual(state.comments.count, 4) } diff --git a/EhPandaTests/Resources/Parser/Gallery/GalleryDetailWithGreeting.html b/EhPandaTests/Resources/Parser/Gallery/GalleryDetailWithGreeting.html index 43fc2f9b..5edc780c 100644 --- a/EhPandaTests/Resources/Parser/Gallery/GalleryDetailWithGreeting.html +++ b/EhPandaTests/Resources/Parser/Gallery/GalleryDetailWithGreeting.html @@ -1,4 +1,4 @@ - + [PIXIV] 塩ラー (29804833) - E-Hentai Galleries